+ Tips
+ Memberships - DDD - FutureCreator - UserTransactions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.Identity;
|
||||
using Hutopy.Infrastructure.Utils;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Hutopy.Web.Controllers;
|
||||
|
||||
public class FacebookController(IIdentityService identityService) : Controller
|
||||
public class FacebookController(
|
||||
IdentityService identityService)
|
||||
: Controller
|
||||
{
|
||||
[Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")]
|
||||
public async Task SignIn()
|
||||
@@ -33,7 +35,7 @@ public class FacebookController(IIdentityService identityService) : Controller
|
||||
var claimsIdentity = new ClaimsIdentity(
|
||||
new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, name),
|
||||
new(ClaimTypes.Name, name),
|
||||
new(ClaimTypes.Email, email),
|
||||
new(ClaimTypes.GivenName, givenName),
|
||||
new(ClaimTypes.Surname, familyName)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System.Text;
|
||||
using Azure.Identity;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.Data;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
using Hutopy.Web.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.Facebook;
|
||||
using Microsoft.AspNetCore.Authentication.Google;
|
||||
@@ -19,15 +16,11 @@ public static class DependencyInjection
|
||||
{
|
||||
services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
|
||||
services.AddScoped<IUser, CurrentUser>();
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddDbContextCheck<ApplicationDbContext>();
|
||||
|
||||
services.AddExceptionHandler<CustomExceptionHandler>();
|
||||
|
||||
services.AddRazorPages();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using Hutopy.Application.Common.Models;
|
||||
using Hutopy.Application.FutureCreators.Commands;
|
||||
using Hutopy.Application.FutureCreators.Queries;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
|
||||
namespace Hutopy.Web.Endpoints;
|
||||
|
||||
public class JoinUs : EndpointGroupBase
|
||||
{
|
||||
public override void Map(WebApplication app)
|
||||
{
|
||||
app.MapGroup(this)
|
||||
.MapGet(GetFutureCreators)
|
||||
.MapPost(CreateFutureCreator);
|
||||
}
|
||||
|
||||
private static Task<Guid> CreateFutureCreator(ISender sender, CreateFutureCreatorCommand command)
|
||||
{
|
||||
return sender.Send(command);
|
||||
}
|
||||
|
||||
private static Task<PaginatedList<FutureCreatorListDto>> GetFutureCreators(ISender sender, [AsParameters] GetFutureCreatorListQuery query)
|
||||
{
|
||||
return sender.Send(query);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Hutopy.Application.Stripe.Commands;
|
||||
using Hutopy.Application.Stripe.Queries;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
|
||||
namespace Hutopy.Web.Endpoints;
|
||||
|
||||
public class Stripe : EndpointGroupBase
|
||||
{
|
||||
public override void Map(WebApplication app)
|
||||
{
|
||||
app.MapGroup(this)
|
||||
.MapPost(ConfirmTransaction, "/confirmTransaction")
|
||||
.MapGet(GetMyLastReceipt, "/getMyLastReceipt")
|
||||
.MapPost(CreateSessionCheckout);
|
||||
}
|
||||
|
||||
private static Task<string> CreateSessionCheckout(ISender sender, CreateSessionCheckoutCommand command)
|
||||
{
|
||||
return sender.Send(command);
|
||||
}
|
||||
|
||||
private async static Task<string> ConfirmTransaction(ISender sender, ConfirmStripeTransactionCommand command)
|
||||
{
|
||||
return await sender.Send(command);
|
||||
}
|
||||
|
||||
private static async Task<MyLastReceiptDto> GetMyLastReceipt(ISender sender, [AsParameters] GetMyLastReceiptQuery query)
|
||||
{
|
||||
return await sender.Send(query);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using Hutopy.Application.Users.Commands;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
|
||||
namespace Hutopy.Web.Endpoints;
|
||||
|
||||
public class UpdateMyUser : EndpointGroupBase
|
||||
{
|
||||
public override void Map(WebApplication app)
|
||||
{
|
||||
app.MapGroup(this)
|
||||
.RequireAuthorization()
|
||||
.MapPatch("/profile", UpdateCurrentUser);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCurrentUser(ISender sender, UpdateCurrentUserCommand command)
|
||||
{
|
||||
return await sender.Send(command);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Hutopy.Application.Users.Commands;
|
||||
using Hutopy.Application.Users.Queries.GetUser;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
|
||||
namespace Hutopy.Web.Endpoints;
|
||||
|
||||
public class Users : EndpointGroupBase
|
||||
{
|
||||
public override void Map(WebApplication app)
|
||||
{
|
||||
app.MapGroup(this)
|
||||
.MapPost(CreateUser)
|
||||
.MapPost(Login, "/login")
|
||||
.MapGet(GetUserById, "/id")
|
||||
.MapGet(GetUserByUserName, "/user-name");
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateUser(ISender sender, CreateUserCommand command)
|
||||
{
|
||||
return await sender.Send(command);
|
||||
}
|
||||
|
||||
private static async Task<UserDto> GetUserById(ISender sender,
|
||||
[AsParameters] GetUserByIdQuery query)
|
||||
{
|
||||
return await sender.Send(query);
|
||||
}
|
||||
|
||||
private static async Task<UserDto> GetUserByUserName(ISender sender,
|
||||
[AsParameters] GetUserByUserNameQuery query)
|
||||
{
|
||||
return await sender.Send(query);
|
||||
}
|
||||
|
||||
private static async Task<LoginResponse> Login(ISender sender, LoginCommand command)
|
||||
{
|
||||
return await sender.Send(command);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ public class ContentDbContext(
|
||||
|
||||
public DbSet<Content> Contents => Set<Content>();
|
||||
public DbSet<Creator> Creators => Set<Creator>();
|
||||
public DbSet<Subscription> Subscriptions => Set<Subscription>();
|
||||
public DbSet<Follower> Followers => Set<Follower>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("Content");
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Content>()
|
||||
@@ -34,13 +34,13 @@ public class ContentDbContext(
|
||||
.ToTable(nameof(ContentReaction).Pluralize());
|
||||
|
||||
modelBuilder
|
||||
.Entity<Subscription>()
|
||||
.Entity<Follower>()
|
||||
.HasOne(c => c.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CreatorId);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Subscription>()
|
||||
.Entity<Follower>()
|
||||
.HasKey(s => new { s.CreatedBy, s.CreatorId });
|
||||
|
||||
modelBuilder
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Hutopy.Web.Features.Contents.Data;
|
||||
|
||||
public class Subscription
|
||||
public class Follower
|
||||
{
|
||||
public Guid CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
@@ -1,5 +1,5 @@
|
||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.AzureBlob;
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||
@@ -16,7 +16,7 @@ public record ChangeBannerResponse(
|
||||
[PublicAPI]
|
||||
public class ChangeBannerHandler(
|
||||
ContentDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
AzureBlobStorage blobStorage)
|
||||
: Endpoint<ChangeBannerRequest, ChangeBannerResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.AzureBlob;
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||
@@ -27,7 +27,7 @@ public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
|
||||
[PublicAPI]
|
||||
public class ChangeLogoHandler(
|
||||
ContentDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
AzureBlobStorage blobStorage)
|
||||
: Endpoint<ChangeLogoRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.AzureBlob;
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
using Hutopy.Web.Features.Contents.Handlers.Models;
|
||||
@@ -44,7 +44,7 @@ public sealed class PostContentRequestValidator : Validator<PostContentRequest>
|
||||
}
|
||||
|
||||
public sealed class PostContent(
|
||||
IBlobStorage blobStorage,
|
||||
AzureBlobStorage blobStorage,
|
||||
ContentDbContext context)
|
||||
: Endpoint<PostContentRequest>
|
||||
{
|
||||
|
||||
@@ -5,40 +5,40 @@ using Hutopy.Web.Features.Contents.Handlers.Models;
|
||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class SubscribeToCreatorRequest
|
||||
public sealed class FollowCreatorRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class SubscribeToCreatorHandler(
|
||||
public sealed class FollowCreatorHandler(
|
||||
ContentDbContext context)
|
||||
: Endpoint<SubscribeToCreatorRequest, SubscriptionModel>
|
||||
: Endpoint<FollowCreatorRequest, FollowModel>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/subscribe");
|
||||
Options((o => o.WithTags("Subscriptions")));
|
||||
Post("/api/creators/{CreatorId}/follow");
|
||||
Options((o => o.WithTags("creators")));
|
||||
Description(x => x.Accepts<string>("*/*"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SubscribeToCreatorRequest req,
|
||||
FollowCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await context.Subscriptions.AddAsync(
|
||||
new() { CreatedBy = HttpContext.User.GetUserId(), CreatorId = req.CreatorId },
|
||||
await context.Followers.AddAsync(
|
||||
new Follower { CreatedBy = User.GetUserId(), CreatorId = req.CreatorId },
|
||||
ct);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Where(c => c.Id == req.CreatorId)
|
||||
.Select(c => new SubscriptionModel(
|
||||
.Where(creator => creator.Id == req.CreatorId)
|
||||
.Select(creator => new FollowModel(
|
||||
req.CreatorId,
|
||||
c.Name,
|
||||
c.Images.Logo
|
||||
creator.Name,
|
||||
creator.Images.Logo
|
||||
))
|
||||
.FirstOrDefaultAsync(cancellationToken: ct);
|
||||
|
||||
@@ -50,7 +50,7 @@ public class GetCreatorByAliasHandler(
|
||||
}
|
||||
else
|
||||
{
|
||||
var subscriberCount = await context.Subscriptions.CountAsync(
|
||||
var followerCount = await context.Followers.CountAsync(
|
||||
s => s.CreatorId == creator.Id,
|
||||
cancellationToken: ct);
|
||||
|
||||
@@ -63,7 +63,7 @@ public class GetCreatorByAliasHandler(
|
||||
creator.Socials,
|
||||
creator.Colors,
|
||||
creator.Images,
|
||||
subscriberCount);
|
||||
followerCount);
|
||||
|
||||
await SendAsync(model, cancellation: ct);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Extensions;
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
using Hutopy.Web.Features.Contents.Handlers.Models;
|
||||
|
||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetFollowedContentsRequest
|
||||
{
|
||||
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
|
||||
[BindFrom("last_id")] public Guid? LastId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetFollowedContentsHandler(
|
||||
ContentDbContext context)
|
||||
: Endpoint<GetFollowedContentsRequest, List<ContentModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/contents/followed");
|
||||
Options(o => o.WithTags("Contents"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetFollowedContentsRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
|
||||
var userId = HttpContext.User.GetUserId();
|
||||
|
||||
var userSubscriptionIds = await context
|
||||
.Subscriptions
|
||||
.Where(s => s.CreatedBy == userId)
|
||||
.Select(s => s.CreatorId)
|
||||
.ToListAsync(cancellationToken: ct);
|
||||
|
||||
var query = context.Contents
|
||||
.Where(c => c.DeletedAt == null)
|
||||
.Where(x => userSubscriptionIds.Contains(x.CreatedBy));
|
||||
if (req.LastId.HasValue)
|
||||
{
|
||||
query = query.Where(c => c.Id > req.LastId.Value);
|
||||
}
|
||||
|
||||
query = query.OrderByDescending(c => c.CreatedAt);
|
||||
|
||||
var content = await query
|
||||
.Select(c => new ContentModel
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedBy = c.CreatedBy,
|
||||
CreatedByName = c.Creator!.Name,
|
||||
CreatedByPortraitUrl = c.Creator.Images.Logo,
|
||||
CreatedAt = c.CreatedAt,
|
||||
DeletedBy = c.DeletedBy,
|
||||
DeletedAt = c.DeletedAt,
|
||||
Title = c.Title,
|
||||
Description = c.Description,
|
||||
Urls = c.Urls,
|
||||
Reactions = c.Reactions.Select(x => new ReactionModel
|
||||
{
|
||||
Reaction = x.Reaction.FromEnum(),
|
||||
UserId = x.UserId,
|
||||
UserName = x.UserName
|
||||
}).ToList()
|
||||
})
|
||||
.Take(req.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendAsync(content, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,14 @@ using Hutopy.Web.Features.Contents.Handlers.Models;
|
||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetSubscriptionsHandler(
|
||||
public class GetFollowedCreatorsHandler(
|
||||
ContentDbContext context)
|
||||
: EndpointWithoutRequest<List<SubscriptionModel>>
|
||||
: EndpointWithoutRequest<List<FollowModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/subscriptions");
|
||||
Options((o => o.WithTags("Subscriptions")));
|
||||
Get("/api/creators/followed");
|
||||
Options((o => o.WithTags("Creators")));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
@@ -21,9 +21,9 @@ public class GetSubscriptionsHandler(
|
||||
var userId = HttpContext.User.GetUserId();
|
||||
|
||||
var subscriptions = await context
|
||||
.Subscriptions
|
||||
.Followers
|
||||
.Where(s => s.CreatedBy == userId)
|
||||
.Select(s => new SubscriptionModel(
|
||||
.Select(s => new FollowModel(
|
||||
s.CreatorId,
|
||||
s.Creator!.Name,
|
||||
s.Creator.Images.Logo))
|
||||
@@ -12,4 +12,4 @@ public record struct CreatorModel(
|
||||
Socials Socials,
|
||||
Colors Colors,
|
||||
Images Images,
|
||||
int SubscriberCount);
|
||||
int FollowerCount);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Hutopy.Web.Features.Contents.Handlers.Models;
|
||||
|
||||
[PublicAPI]
|
||||
public record SubscriptionModel(
|
||||
public record FollowModel(
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string? CreatorPortraitUrl);
|
||||
@@ -16,8 +16,8 @@ public class UnsubscribeFromCreatorHandler(
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/unsubscribe");
|
||||
Options((o => o.WithTags("Subscriptions")));
|
||||
Post("/api/creators/{CreatorId}/unfollow");
|
||||
Options((o => o.WithTags("Creators")));
|
||||
Description(x => x.Accepts<string>("*/*"));
|
||||
}
|
||||
|
||||
@@ -25,14 +25,15 @@ public class UnsubscribeFromCreatorHandler(
|
||||
UnsubscribeFromCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscription = new Subscription { CreatorId = req.CreatorId, CreatedBy = HttpContext.User.GetUserId() };
|
||||
|
||||
context.Subscriptions.Attach(subscription);
|
||||
context.Subscriptions.Remove(subscription);
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = new Follower { CreatorId = req.CreatorId, CreatedBy = HttpContext.User.GetUserId() };
|
||||
|
||||
context.Followers.Attach(subscription);
|
||||
context.Followers.Remove(subscription);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
catch (Exception)
|
||||
310
src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs
generated
Normal file
310
src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs
generated
Normal file
@@ -0,0 +1,310 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Web.Features.Contents.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.Contents.Migrations
|
||||
{
|
||||
[DbContext(typeof(ContentDbContext))]
|
||||
[Migration("20241011103653_FromSubscribersToFollowers")]
|
||||
partial class FromSubscribersToFollowers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Content")
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string[]>("Urls")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.ToTable("Contents", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||
{
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("CreatedBy", "CreatorId");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.ToTable("Followers", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("ContentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("Reaction")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b1.HasKey("ContentId", "Id");
|
||||
|
||||
b1.ToTable("ContentReactions", "Content");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ContentId");
|
||||
});
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Reactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Background")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("Error")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("OnBackground")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("OnError")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("OnPrimary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("OnSecondary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("OnSurface")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("Primary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("Secondary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.Property<string>("Surface")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
.HasColumnType("character varying(9)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Colors", "Content");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Banner")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("Logo")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Images", "Content");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Content");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Colors")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Images")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Web.Features.Contents.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FromSubscribersToFollowers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Subscriptions",
|
||||
schema: "Content");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Followers",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Followers", x => new { x.CreatedBy, x.CreatorId });
|
||||
table.ForeignKey(
|
||||
name: "FK_Followers_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Content",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Followers_CreatorId",
|
||||
schema: "Content",
|
||||
table: "Followers",
|
||||
column: "CreatorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Followers",
|
||||
schema: "Content");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Subscriptions",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Subscriptions", x => new { x.CreatedBy, x.CreatorId });
|
||||
table.ForeignKey(
|
||||
name: "FK_Subscriptions_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Content",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Subscriptions_CreatorId",
|
||||
schema: "Content",
|
||||
table: "Subscriptions",
|
||||
column: "CreatorId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
||||
b.ToTable("Creators", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||
{
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
@@ -107,7 +107,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.ToTable("Subscriptions", "Content");
|
||||
b.ToTable("Followers", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
|
||||
@@ -294,7 +294,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
|
||||
8
src/Web/Features/Memberships/Data/Creator.cs
Normal file
8
src/Web/Features/Memberships/Data/Creator.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
public class Creator
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string StripeAccountId { get; set; }
|
||||
}
|
||||
58
src/Web/Features/Memberships/Data/MembershipDbContext.cs
Normal file
58
src/Web/Features/Memberships/Data/MembershipDbContext.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
public sealed class MembershipDbContext(
|
||||
DbContextOptions<MembershipDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Membership";
|
||||
|
||||
public DbSet<Creator> Creators => Set<Creator>();
|
||||
public DbSet<Subscription> Subscriptions => Set<Subscription>();
|
||||
public DbSet<Tier> Tiers => Set<Tier>();
|
||||
public DbSet<Tip> Tips => Set<Tip>();
|
||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<Creator>();
|
||||
|
||||
modelBuilder
|
||||
.Entity<Subscription>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Subscription>()
|
||||
.HasOne(c => c.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CreatorId);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Tier>()
|
||||
.HasOne(c => c.Creator)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CreatorId);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Tier>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Tip>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Transaction>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
public static class InitializerExtensions
|
||||
{
|
||||
public static async Task InitialiseMembershipDbContextAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<MembershipDbContextInitializer>();
|
||||
|
||||
await initializer.InitialiseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public class MembershipDbContextInitializer(
|
||||
ILogger<MembershipDbContextInitializer> logger,
|
||||
ContentDbContext context
|
||||
)
|
||||
{
|
||||
public async Task InitialiseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await context.Database.MigrateAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "An error occurred while initialising the content database.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Web/Features/Memberships/Data/Subscription.cs
Normal file
18
src/Web/Features/Memberships/Data/Subscription.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
public class Subscription
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid CreatorId { get; set; }
|
||||
public Creator Creator { get; set; }
|
||||
public Guid TierId { get; set; }
|
||||
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; }
|
||||
|
||||
}
|
||||
14
src/Web/Features/Memberships/Data/Tier.cs
Normal file
14
src/Web/Features/Memberships/Data/Tier.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
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 decimal Price { get; set; }
|
||||
public string CurrencyCode { get; set; }
|
||||
|
||||
public ICollection<Subscription> Subscriptions { get; set; }
|
||||
}
|
||||
15
src/Web/Features/Memberships/Data/Tip.cs
Normal file
15
src/Web/Features/Memberships/Data/Tip.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
public class Tip
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string StripeSessionId { get; set; }
|
||||
public Guid TipperId { get; set; }
|
||||
public string TipperName { get; set; }
|
||||
public Guid CreatorId { get; set; }
|
||||
public string CreatorName { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
11
src/Web/Features/Memberships/Data/Transaction.cs
Normal file
11
src/Web/Features/Memberships/Data/Transaction.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
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 Type { get; set; } // Subscription, Tip
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
27
src/Web/Features/Memberships/DependencyInjection.cs
Normal file
27
src/Web/Features/Memberships/DependencyInjection.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Memberships.Services;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddMembershipModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddSingleton<PushNotificationService>();
|
||||
|
||||
builder.Services.AddDbContext<MembershipDbContext>(configureAction);
|
||||
builder.Services.AddScoped<MembershipDbContextInitializer>();
|
||||
|
||||
builder.Services.AddScoped<StripeService>();
|
||||
|
||||
builder.Services
|
||||
.AddOptions<StripeOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Stripe"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
7
src/Web/Features/Memberships/Events/SubscriptionPaid.cs
Normal file
7
src/Web/Features/Memberships/Events/SubscriptionPaid.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Events;
|
||||
|
||||
public record struct SubscriptionPaid(
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string Tier,
|
||||
DateTimeOffset Since);
|
||||
8
src/Web/Features/Memberships/Events/TipPaid.cs
Normal file
8
src/Web/Features/Memberships/Events/TipPaid.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Events;
|
||||
|
||||
public record struct TipPaid(
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string Message);
|
||||
48
src/Web/Features/Memberships/Handlers/CancelSubscription.cs
Normal file
48
src/Web/Features/Memberships/Handlers/CancelSubscription.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Memberships.Services;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class CancelSubscriptionRequest
|
||||
{
|
||||
public Guid SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
public class CancelSubscriptionHandler(
|
||||
MembershipDbContext dbDbContext,
|
||||
StripeService stripeService)
|
||||
: Endpoint<CancelSubscriptionRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/membership/unsubscribe");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancelSubscriptionRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscription = await dbDbContext
|
||||
.Subscriptions
|
||||
.FindAsync(
|
||||
[req.SubscriptionId],
|
||||
cancellationToken: ct);
|
||||
|
||||
if (subscription is not { EndDate: null })
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel Stripe subscription
|
||||
await stripeService.CancelSubscription(subscription.Id);
|
||||
|
||||
// Update subscription in the system
|
||||
subscription.EndDate = DateTime.UtcNow;
|
||||
await dbDbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(subscription.Id, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateMembershipTierEndpoint(
|
||||
MembershipDbContext dbDbContext)
|
||||
: Endpoint<CreateMembershipTierRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/membership/tiers");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateMembershipTierRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tier = dbDbContext
|
||||
.Tiers
|
||||
.Add(new Tier { CreatorId = req.CreatorId, Price = req.Price, Name = req.Name });
|
||||
|
||||
await dbDbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(tier, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetActiveSubscriptionsRequest;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetActiveSubscriptionsHandler(
|
||||
MembershipDbContext dbDbContext)
|
||||
: Endpoint<GetActiveSubscriptionsRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/membership/active");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetActiveSubscriptionsRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscriptions = await dbDbContext
|
||||
.Subscriptions
|
||||
.Where(subscription => subscription.UserId == User.GetUserId())
|
||||
.Where(subscription => subscription.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(subscriptions, ct);
|
||||
}
|
||||
}
|
||||
47
src/Web/Features/Memberships/Handlers/GetMembershipTier.cs
Normal file
47
src/Web/Features/Memberships/Handlers/GetMembershipTier.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetMembershipTierRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct TierModel(
|
||||
Guid Id,
|
||||
DateTime CreatedAt,
|
||||
string Name,
|
||||
decimal Price,
|
||||
string CurrencyCode);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetMembershipTierEndpoint(
|
||||
MembershipDbContext dbDbContext)
|
||||
: Endpoint<CreateMembershipTierRequest, List<TierModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/membership/tiers");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateMembershipTierRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tiers = await dbDbContext
|
||||
.Tiers
|
||||
.Where(tier => tier.CreatorId == req.CreatorId)
|
||||
.Select(tier => new TierModel(
|
||||
tier.Id,
|
||||
tier.CreatedAt,
|
||||
tier.Name,
|
||||
tier.Price,
|
||||
tier.CurrencyCode))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tiers, ct);
|
||||
}
|
||||
}
|
||||
45
src/Web/Features/Memberships/Handlers/GetReceivedTips.cs
Normal file
45
src/Web/Features/Memberships/Handlers/GetReceivedTips.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct TipReceivedModel(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
Guid TipperId,
|
||||
string TipperName,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetReceivedTipsHandler(
|
||||
MembershipDbContext dbDbContext)
|
||||
: EndpointWithoutRequest<List<TipReceivedModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/tips/received");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tipsReceived = await dbDbContext
|
||||
.Tips
|
||||
.Where(tip => tip.CreatorId == User.GetUserId())
|
||||
.Select(tip => new TipReceivedModel(
|
||||
tip.Id,
|
||||
tip.CreatedAt,
|
||||
tip.TipperId,
|
||||
tip.TipperName,
|
||||
tip.Amount,
|
||||
tip.Currency,
|
||||
tip.Message))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tipsReceived, ct);
|
||||
}
|
||||
}
|
||||
45
src/Web/Features/Memberships/Handlers/GetSentTips.cs
Normal file
45
src/Web/Features/Memberships/Handlers/GetSentTips.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct TipSentModel(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
decimal Amount,
|
||||
string Currency,
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetSentTipsHandler(
|
||||
MembershipDbContext dbContext)
|
||||
: EndpointWithoutRequest<List<TipSentModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/tips/sent");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tips = await dbContext
|
||||
.Tips
|
||||
.Where(t => t.TipperId == User.GetUserId())
|
||||
.Select(tip => new TipSentModel(
|
||||
tip.Id,
|
||||
tip.CreatedAt,
|
||||
tip.CreatorId,
|
||||
tip.CreatorName,
|
||||
tip.Amount,
|
||||
tip.Currency,
|
||||
tip.Message))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tips, ct);
|
||||
}
|
||||
}
|
||||
71
src/Web/Features/Memberships/Handlers/HandleStripe.cs
Normal file
71
src/Web/Features/Memberships/Handlers/HandleStripe.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Memberships.Services;
|
||||
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<StripeOptions> options)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
using var streamReader = new StreamReader(HttpContext.Request.Body);
|
||||
var json = await streamReader.ReadToEndAsync(ct);
|
||||
|
||||
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
|
||||
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case StripeEvents.InvoicePaymentSucceeded:
|
||||
await stripeService.HandlePaymentSucceeded(stripeEvent, ct);
|
||||
break;
|
||||
case StripeEvents.InvoicePaymentFailed:
|
||||
await stripeService.HandlePaymentFailed(stripeEvent, ct);
|
||||
break;
|
||||
case StripeEvents.CheckoutSessionCompleted:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
117
src/Web/Features/Memberships/Handlers/SendTip.cs
Normal file
117
src/Web/Features/Memberships/Handlers/SendTip.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Memberships.Services;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SendTipResponse
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required string StripeCheckoutUrl { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SendTipRequestValidator : Validator<SendTipRequest>
|
||||
{
|
||||
public SendTipRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Amount)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Tip amount must be greater than 0");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SendTipHandler(
|
||||
MembershipDbContext dbDbContext,
|
||||
StripeService stripeService)
|
||||
: Endpoint<SendTipRequest, SendTipResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/tips/{CreatorId}");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SendTipRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var userName = User.GetName();
|
||||
|
||||
var creator = await dbDbContext.Creators.FindAsync(
|
||||
[req.CreatorId],
|
||||
cancellationToken: ct);
|
||||
if (creator == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var checkoutSession = await stripeService.CreateTipCheckoutSession(
|
||||
userId,
|
||||
req.Amount,
|
||||
req.Currency,
|
||||
creator.Id,
|
||||
creator.Name,
|
||||
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 },
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
110
src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs
Normal file
110
src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Hutopy.Web.Common;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Memberships.Services;
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
public Guid TierId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct SubscriptionResponse(
|
||||
Guid SubscriptionId,
|
||||
Guid CreatorId,
|
||||
Guid UserId,
|
||||
bool IsActive,
|
||||
string Tier,
|
||||
DateTimeOffset StartDate,
|
||||
DateTimeOffset? EndDate);
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeValidator : Validator<SubscribeRequest>
|
||||
{
|
||||
public SubscribeValidator()
|
||||
{
|
||||
RuleFor(x => x.TierId).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeHandler(
|
||||
MembershipDbContext dbDbContext,
|
||||
StripeService stripeService)
|
||||
: Endpoint<SubscribeRequest, SubscriptionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/membership/subscribe");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SubscribeRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tier = await dbDbContext
|
||||
.Tiers
|
||||
.Include(tier => tier.Creator) // Include the related table
|
||||
.Where(tier => tier.Id == req.TierId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (tier == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process Stripe subscription
|
||||
var stripeSubscription = await stripeService.CreateSubscriptionCheckoutSession(
|
||||
User.GetUserId(),
|
||||
tier.Price,
|
||||
tier.CurrencyCode,
|
||||
$"{tier.Name} from {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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
233
src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs
generated
Normal file
233
src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,233 @@
|
||||
// <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.Migrations
|
||||
{
|
||||
[DbContext(typeof(MembershipDbContext))]
|
||||
[Migration("20241011100852_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Membership")
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
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.Membership.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()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.ToTable("Tiers", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.HasKey("Id");
|
||||
|
||||
b.ToTable("Tips", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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>("StripeCheckoutSessionId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Transactions", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("TierId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Tier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Membership");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Creators",
|
||||
schema: "Membership",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
StripeAccountId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
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(
|
||||
name: "Tiers",
|
||||
schema: "Membership",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
CurrencyCode = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tiers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Tiers_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Membership",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Subscriptions",
|
||||
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"),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = 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),
|
||||
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
StripeSessionId = table.Column<string>(type: "text", nullable: true),
|
||||
StripeSubscriptionId = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Subscriptions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Subscriptions_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Membership",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Subscriptions_Tiers_TierId",
|
||||
column: x => x.TierId,
|
||||
principalSchema: "Membership",
|
||||
principalTable: "Tiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Subscriptions_CreatorId",
|
||||
schema: "Membership",
|
||||
table: "Subscriptions",
|
||||
column: "CreatorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Subscriptions_TierId",
|
||||
schema: "Membership",
|
||||
table: "Subscriptions",
|
||||
column: "TierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tiers_CreatorId",
|
||||
schema: "Membership",
|
||||
table: "Tiers",
|
||||
column: "CreatorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Subscriptions",
|
||||
schema: "Membership");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tips",
|
||||
schema: "Membership");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Transactions",
|
||||
schema: "Membership");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tiers",
|
||||
schema: "Membership");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Creators",
|
||||
schema: "Membership");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Web.Features.Memberships.Migrations
|
||||
{
|
||||
[DbContext(typeof(MembershipDbContext))]
|
||||
partial class MembershipDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Membership")
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
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.Membership.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()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.ToTable("Tiers", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.HasKey("Id");
|
||||
|
||||
b.ToTable("Tips", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.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>("StripeCheckoutSessionId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Transactions", "Membership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("TierId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Tier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Hutopy.Web.Features.Memberships.Services;
|
||||
|
||||
public sealed class PushNotificationService(
|
||||
ILogger<PushNotificationService> logger)
|
||||
{
|
||||
public void NotifyCreator<TEvent>(
|
||||
Guid tipCreatorId,
|
||||
TEvent notification)
|
||||
where TEvent : struct
|
||||
{
|
||||
logger.LogInformation("Notifying creator {CreatorId}, {Notification}", tipCreatorId, notification);
|
||||
}
|
||||
}
|
||||
224
src/Web/Features/Memberships/Services/StripeService.cs
Normal file
224
src/Web/Features/Memberships/Services/StripeService.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.AzureBlob;
|
||||
using Hutopy.Infrastructure.Identity;
|
||||
using Hutopy.Web.Common;
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitReq
|
||||
[PublicAPI]
|
||||
public class ChangePortraitHandler(
|
||||
ApplicationUserManager userManager,
|
||||
IBlobStorage blobStorage)
|
||||
AzureBlobStorage blobStorage)
|
||||
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.Identity;
|
||||
using Hutopy.Web.Features.Users.Handlers.Models;
|
||||
|
||||
namespace Hutopy.Web.Features.Users.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserQueryHandler(
|
||||
IIdentityService identityService
|
||||
)
|
||||
IdentityService identityService)
|
||||
: EndpointWithoutRequest<UserDto>
|
||||
{
|
||||
public override void Configure()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Infrastructure.AzureBlob;
|
||||
using Hutopy.Infrastructure.Identity;
|
||||
|
||||
namespace Hutopy.Web.Features.Users.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserPortraitHandler(
|
||||
IIdentityService identityService,
|
||||
IBlobStorage blobStorage
|
||||
IdentityService identityService,
|
||||
AzureBlobStorage blobStorage
|
||||
)
|
||||
: EndpointWithoutRequest<Stream>
|
||||
{
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace Hutopy.Web.Features.Wallets;
|
||||
|
||||
public class UserTransactionDto
|
||||
{
|
||||
public required decimal Amount { get; init; }
|
||||
|
||||
public string Currency { get; init; } = "cad";
|
||||
|
||||
public string TipMessage { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset Created { get; init; }
|
||||
|
||||
public bool IsConfirmed { get; init; }
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using Hutopy.Application.Common.Exceptions;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProblemDetails = Microsoft.AspNetCore.Mvc.ProblemDetails;
|
||||
using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException;
|
||||
|
||||
namespace Hutopy.Web.Infrastructure;
|
||||
|
||||
public class CustomExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers;
|
||||
|
||||
public CustomExceptionHandler()
|
||||
{
|
||||
// Register known exception types and handlers.
|
||||
_exceptionHandlers = new Dictionary<Type, Func<HttpContext, Exception, Task>>
|
||||
{
|
||||
{ typeof(ValidationException), HandleValidationException },
|
||||
{ typeof(NotFoundException), HandleNotFoundException },
|
||||
{ typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException },
|
||||
{ typeof(ForbiddenAccessException), HandleForbiddenAccessException },
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
var exceptionType = exception.GetType();
|
||||
|
||||
if (!_exceptionHandlers.TryGetValue(exceptionType, out Func<HttpContext, Exception, Task>? value)) return false;
|
||||
|
||||
await value.Invoke(httpContext, exception);
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
private static async Task HandleValidationException(HttpContext httpContext, Exception ex)
|
||||
{
|
||||
var exception = (ValidationException)ex;
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors)
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleNotFoundException(HttpContext httpContext, Exception ex)
|
||||
{
|
||||
var exception = (NotFoundException)ex;
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails()
|
||||
{
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
|
||||
Title = "The specified resource was not found.",
|
||||
Detail = exception.Message
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleUnauthorizedAccessException(HttpContext httpContext, Exception ex)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Type = "https://tools.ietf.org/html/rfc7235#section-3.1"
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleForbiddenAccessException(HttpContext httpContext, Exception ex)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status403Forbidden,
|
||||
Title = "Forbidden",
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Hutopy.Web.Infrastructure;
|
||||
|
||||
public abstract class EndpointGroupBase
|
||||
{
|
||||
public abstract void Map(WebApplication app);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Hutopy.Web.Infrastructure;
|
||||
|
||||
public static class IEndpointRouteBuilderExtensions
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "")
|
||||
{
|
||||
Guard.Against.AnonymousMethod(handler);
|
||||
|
||||
builder.MapGet(pattern, handler)
|
||||
.WithName(handler.Method.Name);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapPost(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "")
|
||||
{
|
||||
Guard.Against.AnonymousMethod(handler);
|
||||
|
||||
builder.MapPost(pattern, handler)
|
||||
.WithName(handler.Method.Name);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapPut(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern)
|
||||
{
|
||||
Guard.Against.AnonymousMethod(handler);
|
||||
|
||||
builder.MapPut(pattern, handler)
|
||||
.WithName(handler.Method.Name);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern)
|
||||
{
|
||||
Guard.Against.AnonymousMethod(handler);
|
||||
|
||||
builder.MapDelete(pattern, handler)
|
||||
.WithName(handler.Method.Name);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Hutopy.Web.Infrastructure;
|
||||
|
||||
public static class MethodInfoExtensions
|
||||
{
|
||||
private static bool IsAnonymous(this MethodInfo method)
|
||||
{
|
||||
var invalidChars = new[] { '<', '>' };
|
||||
return method.Name.Any(invalidChars.Contains);
|
||||
}
|
||||
|
||||
public static void AnonymousMethod(this IGuardClause guardClause, Delegate input)
|
||||
{
|
||||
if (input.Method.IsAnonymous())
|
||||
throw new ArgumentException("The endpoint name must be specified when using anonymous handlers.");
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Hutopy.Web.Infrastructure;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
public static RouteGroupBuilder MapGroup(this WebApplication app, EndpointGroupBase group)
|
||||
{
|
||||
var groupName = group.GetType().Name;
|
||||
|
||||
return app
|
||||
.MapGroup($"/api/{groupName}")
|
||||
.WithGroupName(groupName)
|
||||
.WithTags(groupName)
|
||||
.WithOpenApi();
|
||||
}
|
||||
|
||||
public static WebApplication MapEndpoints(this WebApplication app)
|
||||
{
|
||||
var endpointGroupType = typeof(EndpointGroupBase);
|
||||
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
var endpointGroupTypes = assembly.GetExportedTypes()
|
||||
.Where(t => t.IsSubclassOf(endpointGroupType));
|
||||
|
||||
foreach (var type in endpointGroupTypes)
|
||||
{
|
||||
if (Activator.CreateInstance(type) is EndpointGroupBase instance)
|
||||
{
|
||||
instance.Map(app);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ using Hutopy.Infrastructure.Identity;
|
||||
using Hutopy.Web;
|
||||
using Hutopy.Web.Features.Contents;
|
||||
using Hutopy.Web.Features.Contents.Data;
|
||||
using Hutopy.Web.Features.Memberships;
|
||||
using Hutopy.Web.Features.Memberships.Data;
|
||||
using Hutopy.Web.Features.Messages;
|
||||
using Hutopy.Web.Features.Messages.Data;
|
||||
using Hutopy.Web.Infrastructure;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using NSwag;
|
||||
using NSwag.Generation.AspNetCore.Processors;
|
||||
@@ -51,7 +52,6 @@ builder.Services.AddCors(options =>
|
||||
// Add services to the container.
|
||||
builder.Services.AddKeyVaultIfConfigured(builder.Configuration);
|
||||
|
||||
builder.Services.AddApplicationServices();
|
||||
builder.Services.AddInfrastructureServices(builder.Configuration);
|
||||
builder.Services.AddWebServices();
|
||||
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||
@@ -59,7 +59,9 @@ builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||
// TODO: This old tech should be remove - need to move Facebook / Google controllers to FastEndpoints
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddOpenApiDocument((configure, sp) =>
|
||||
builder.Services.AddOpenApiDocument((
|
||||
configure,
|
||||
sp) =>
|
||||
{
|
||||
configure.Title = "Hutopy API";
|
||||
|
||||
@@ -91,6 +93,10 @@ builder.Services.AddMessagingModule(options =>
|
||||
options.UseNpgsql(
|
||||
postgresConnectionString,
|
||||
o => o.MigrationsHistoryTable("__EFMigrationsHistory", MessagingDbContext.SchemaName)));
|
||||
builder.AddMembershipModule(
|
||||
options => options.UseNpgsql(
|
||||
postgresConnectionString,
|
||||
o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName)));
|
||||
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||
|
||||
@@ -111,6 +117,7 @@ app.UseAuthorization();
|
||||
await app.InitialiseApplicationDatabaseAsync();
|
||||
await app.InitialiseContentDbContextAsync();
|
||||
await app.InitialiseMessagingDbContextAsync();
|
||||
await app.InitialiseMembershipDbContextAsync();
|
||||
await app.SeedDatabaseWithTestDataOnlyIfNoDataIsPresentAsync();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
@@ -134,10 +141,6 @@ app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller}/{action=Index}/{id?}");
|
||||
|
||||
//TODO: validate the behavior
|
||||
// app.UseExceptionHandler();
|
||||
app.MapEndpoints();
|
||||
|
||||
app.UseFastEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using Hutopy.Application.Common.Interfaces;
|
||||
using Hutopy.Web.Common;
|
||||
|
||||
namespace Hutopy.Web.Services;
|
||||
|
||||
public class CurrentUser(
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: IUser
|
||||
{
|
||||
public Guid? Id => httpContextAccessor.HttpContext?.User.GetUserId();
|
||||
}
|
||||
@@ -49,7 +49,7 @@ internal class TestDataSeeder(
|
||||
creator.Id = creatorUser.Id;
|
||||
creator.CreatedBy = creator.Id;
|
||||
|
||||
await contentContext.Subscriptions.AddAsync(new Subscription
|
||||
await contentContext.Followers.AddAsync(new Follower
|
||||
{
|
||||
CreatedBy = userA.Id, CreatorId = creator.Id
|
||||
});
|
||||
|
||||
@@ -75,14 +75,28 @@ Authorization: Bearer {{auth_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"Primary" : "#fffff0",
|
||||
"Secondary" : "#fffff0",
|
||||
"Background" : "#fffff0",
|
||||
"Surface" : "#fffff0",
|
||||
"Error" : "#fffff0",
|
||||
"OnPrimary" : "#fffff0",
|
||||
"OnSecondary" : "#fffff0",
|
||||
"OnBackground" : "#fffff0",
|
||||
"OnSurface" : "#fffff0",
|
||||
"OnError" : "#fffff0"
|
||||
"Primary": "#fffff0",
|
||||
"Secondary": "#fffff0",
|
||||
"Background": "#fffff0",
|
||||
"Surface": "#fffff0",
|
||||
"Error": "#fffff0",
|
||||
"OnPrimary": "#fffff0",
|
||||
"OnSecondary": "#fffff0",
|
||||
"OnBackground": "#fffff0",
|
||||
"OnSurface": "#fffff0",
|
||||
"OnError": "#fffff0"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
# GET /api/tips/{CreatorId}
|
||||
POST {{base_url}}/api/tips/8590ba59-58a7-4466-bb50-08dcb5e47c6d/
|
||||
Authorization: Bearer {{auth_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount" : 12300,
|
||||
"creatorId" : "9a150dea-edda-4b85-f17a-08dce560fa5c",
|
||||
"currency" : "CAD",
|
||||
"message" : "TEST"
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
}
|
||||
},
|
||||
"Stripe": {
|
||||
"apiKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI"
|
||||
"SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI",
|
||||
"WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1",
|
||||
"HutopyRate": 0.05
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user