Add project-scoped patronage checkout
This commit is contained in:
parent
2a382fb4a8
commit
bd3a5a974b
10 changed files with 396 additions and 56 deletions
|
|
@ -360,6 +360,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.WithMany(x => x.PatronSupportEvents)
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.PatronSupportEvents)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DecayRun>(entity =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPatronSupportProjectAttribution : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ProjectId",
|
||||
table: "PatronSupportEvents",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PatronSupportEvents_ProjectId",
|
||||
table: "PatronSupportEvents",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_PatronSupportEvents_Projects_ProjectId",
|
||||
table: "PatronSupportEvents",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_PatronSupportEvents_Projects_ProjectId",
|
||||
table: "PatronSupportEvents");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PatronSupportEvents_ProjectId",
|
||||
table: "PatronSupportEvents");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectId",
|
||||
table: "PatronSupportEvents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -908,6 +908,9 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
|
@ -923,6 +926,8 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("\"ProviderEventId\" <> ''");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
|
||||
|
||||
b.ToTable("PatronSupportEvents");
|
||||
|
|
@ -1734,12 +1739,19 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.Project", "Project")
|
||||
.WithMany("PatronSupportEvents")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany("PatronSupportEvents")
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
|
|
@ -1998,6 +2010,8 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
b.Navigation("Motions");
|
||||
|
||||
b.Navigation("PatronSupportEvents");
|
||||
|
||||
b.Navigation("PointTransactions");
|
||||
|
||||
b.Navigation("RevenueEvents");
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ public sealed class Project
|
|||
|
||||
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
|
||||
|
||||
public ICollection<PatronSupportEvent> PatronSupportEvents { get; set; } = [];
|
||||
|
||||
public ICollection<RevenueEvent> RevenueEvents { get; set; } = [];
|
||||
|
||||
public ICollection<RevenueShareLine> RevenueShareLines { get; set; } = [];
|
||||
|
|
@ -801,6 +803,8 @@ public sealed class PatronSupportEvent
|
|||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
|
||||
public string ExternalSupportId { get; set; } = string.Empty;
|
||||
|
||||
public ExternalPatronProvider Provider { get; set; } = ExternalPatronProvider.Manual;
|
||||
|
|
@ -826,6 +830,8 @@ public sealed class PatronSupportEvent
|
|||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
|
||||
public Project? Project { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DecayRun
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ public sealed class PatronageService(
|
|||
DateTimeOffset supportedAtUtc,
|
||||
bool isCurrentRecurringSupport,
|
||||
string notes,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
Guid? projectId = null)
|
||||
{
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
|
|
@ -88,6 +89,7 @@ public sealed class PatronageService(
|
|||
var supportEvent = new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
ProjectId = projectId,
|
||||
Kind = kind,
|
||||
ExternalSupportId = externalSupportId.Trim(),
|
||||
Provider = provider,
|
||||
|
|
@ -106,6 +108,7 @@ public sealed class PatronageService(
|
|||
dbContext.PointTransactions.Add(new PointTransaction
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
ProjectId = projectId,
|
||||
Type = PointTransactionType.PatronSupport,
|
||||
Amount = amount,
|
||||
IsDecaying = kind == PatronSupportEventKind.OneTimeDonation,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,33 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Patronage;
|
||||
|
||||
public sealed class StripeCheckoutService(
|
||||
HttpClient httpClient,
|
||||
BifrostDbContext dbContext,
|
||||
IOptions<StripeOptions> options)
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, StripePatronageTier> VelvetTiers =
|
||||
new Dictionary<string, StripePatronageTier>(StringComparer.OrdinalIgnoreCase)
|
||||
private static readonly IReadOnlyDictionary<string, StripePatronageProjectPolicy> ProjectPolicies =
|
||||
new Dictionary<string, StripePatronageProjectPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["velvet-room"] = new(
|
||||
"velvet-room",
|
||||
"GameCult Patronage - Velvet Room",
|
||||
"General GameCult patronage with Deru supporter access.",
|
||||
1900,
|
||||
"usd"),
|
||||
["mirror-test"] = new(
|
||||
"mirror-test",
|
||||
"GameCult Patronage - Mirror Test",
|
||||
"General GameCult patronage with Deru supporter access.",
|
||||
1200,
|
||||
"usd"),
|
||||
["after-hours-bundle"] = new(
|
||||
"after-hours-bundle",
|
||||
"GameCult Patronage - After Hours Bundle",
|
||||
"General GameCult patronage with Deru supporter access.",
|
||||
3900,
|
||||
"usd")
|
||||
["velvet"] = new(
|
||||
"velvet",
|
||||
"Velvet",
|
||||
"velvet.gamecult.org",
|
||||
"deru",
|
||||
"usd",
|
||||
100,
|
||||
500_00)
|
||||
};
|
||||
|
||||
public async Task<StripeCheckoutResult> CreateVelvetCheckoutAsync(
|
||||
string tierSlug,
|
||||
public async Task<StripeCheckoutResult> CreateProjectDonationCheckoutAsync(
|
||||
StripeDonationCheckoutRequest checkoutRequest,
|
||||
UserAccount patronAccount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -44,14 +37,16 @@ public sealed class StripeCheckoutService(
|
|||
return StripeCheckoutResult.NotConfigured("Stripe checkout is not configured.");
|
||||
}
|
||||
|
||||
if (!VelvetTiers.TryGetValue(tierSlug.Trim(), out var tier))
|
||||
var validation = await ValidateAsync(checkoutRequest, cancellationToken);
|
||||
if (validation.Status != StripeCheckoutStatus.Created)
|
||||
{
|
||||
return StripeCheckoutResult.UnknownTier($"Unknown Velvet patronage tier '{tierSlug}'.");
|
||||
return validation;
|
||||
}
|
||||
|
||||
var donation = validation.Donation!;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/checkout/sessions");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", stripeOptions.SecretKey);
|
||||
request.Content = new FormUrlEncodedContent(BuildCheckoutForm(stripeOptions, tier, patronAccount));
|
||||
request.Content = new FormUrlEncodedContent(BuildCheckoutForm(stripeOptions, donation, patronAccount));
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
|
@ -74,20 +69,97 @@ public sealed class StripeCheckoutService(
|
|||
return StripeCheckoutResult.Created(new Uri(url));
|
||||
}
|
||||
|
||||
private async Task<StripeCheckoutResult> ValidateAsync(
|
||||
StripeDonationCheckoutRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectSlug = request.ProjectSlug.Trim();
|
||||
if (string.IsNullOrWhiteSpace(projectSlug))
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest("Missing required project path segment.");
|
||||
}
|
||||
|
||||
if (!ProjectPolicies.TryGetValue(projectSlug, out var policy))
|
||||
{
|
||||
return StripeCheckoutResult.UnknownProject($"Unknown patronage project '{projectSlug}'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.QueryProjectSlug?.Trim(), projectSlug, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest("Project query parameter must match the checkout project.");
|
||||
}
|
||||
|
||||
var project = await dbContext.Projects
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Slug == policy.ProjectSlug)
|
||||
.OrderBy(x => x.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (project is null || project.Status != ProjectStatus.Active)
|
||||
{
|
||||
return StripeCheckoutResult.UnknownProject($"Patronage project '{policy.ProjectSlug}' is not active in Bifrost.");
|
||||
}
|
||||
|
||||
if (request.AmountInMinorUnits < policy.MinimumAmountInMinorUnits ||
|
||||
request.AmountInMinorUnits > policy.MaximumAmountInMinorUnits)
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest(
|
||||
$"Donation amount must be between {policy.MinimumAmountInMinorUnits} and {policy.MaximumAmountInMinorUnits} {policy.Currency} minor units.");
|
||||
}
|
||||
|
||||
var currency = string.IsNullOrWhiteSpace(request.Currency)
|
||||
? policy.Currency
|
||||
: request.Currency.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(currency, policy.Currency, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest($"Project '{policy.ProjectSlug}' only accepts {policy.Currency.ToUpperInvariant()} donations.");
|
||||
}
|
||||
|
||||
var itemSlug = request.ItemSlug.Trim();
|
||||
if (!IsValidSlugValue(itemSlug, maxLength: 120))
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest("Item must be a non-empty slug of 120 characters or fewer.");
|
||||
}
|
||||
|
||||
var source = string.IsNullOrWhiteSpace(request.Source)
|
||||
? policy.Source
|
||||
: request.Source.Trim();
|
||||
if (!string.Equals(source, policy.Source, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeCheckoutResult.InvalidRequest($"Project '{policy.ProjectSlug}' checkout source must be '{policy.Source}'.");
|
||||
}
|
||||
|
||||
return StripeCheckoutResult.Validated(new StripeDonationCheckout(
|
||||
policy,
|
||||
project.Id,
|
||||
itemSlug,
|
||||
request.AmountInMinorUnits,
|
||||
currency));
|
||||
}
|
||||
|
||||
private static bool IsValidSlugValue(string value, int maxLength) =>
|
||||
value.Length is > 0 and <= 120 &&
|
||||
value.Length <= maxLength &&
|
||||
value.All(character =>
|
||||
char.IsAsciiLetterOrDigit(character) ||
|
||||
character is '-' or '_' or '.');
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> BuildCheckoutForm(
|
||||
StripeOptions options,
|
||||
StripePatronageTier tier,
|
||||
StripeDonationCheckout donation,
|
||||
UserAccount patronAccount)
|
||||
{
|
||||
var referenceId = $"velvet:{tier.Slug}:{patronAccount.Id:N}:{Guid.NewGuid():N}";
|
||||
var policy = donation.Policy;
|
||||
var referenceId = $"{policy.ProjectSlug}:{donation.ItemSlug}:{patronAccount.Id:N}:{Guid.NewGuid():N}";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "velvet.gamecult.org",
|
||||
["source"] = policy.Source,
|
||||
["umbrella"] = "GameCult",
|
||||
["ledger"] = "bifrost",
|
||||
["purpose"] = "general_patronage",
|
||||
["model"] = "deru",
|
||||
["tier"] = tier.Slug,
|
||||
["project"] = policy.ProjectSlug,
|
||||
["project_id"] = donation.ProjectId.ToString("N"),
|
||||
["item"] = donation.ItemSlug,
|
||||
["model"] = policy.Model,
|
||||
["bifrost_user_account_id"] = patronAccount.Id.ToString("N"),
|
||||
["bifrost_github_login"] = patronAccount.GitHubLogin
|
||||
};
|
||||
|
|
@ -97,10 +169,10 @@ public sealed class StripeCheckoutService(
|
|||
yield return new("cancel_url", options.CancelUrl);
|
||||
yield return new("client_reference_id", referenceId);
|
||||
yield return new("line_items[0][quantity]", "1");
|
||||
yield return new("line_items[0][price_data][currency]", tier.Currency);
|
||||
yield return new("line_items[0][price_data][unit_amount]", tier.AmountInMinorUnits.ToString());
|
||||
yield return new("line_items[0][price_data][product_data][name]", tier.Name);
|
||||
yield return new("line_items[0][price_data][product_data][description]", tier.Description);
|
||||
yield return new("line_items[0][price_data][currency]", donation.Currency);
|
||||
yield return new("line_items[0][price_data][unit_amount]", donation.AmountInMinorUnits.ToString());
|
||||
yield return new("line_items[0][price_data][product_data][name]", $"{policy.DisplayName} patronage - {donation.ItemSlug}");
|
||||
yield return new("line_items[0][price_data][product_data][description]", $"General GameCult patronage attributed to {policy.DisplayName}.");
|
||||
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
|
|
@ -110,26 +182,50 @@ public sealed class StripeCheckoutService(
|
|||
}
|
||||
}
|
||||
|
||||
public sealed record StripePatronageTier(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Description,
|
||||
public sealed record StripeDonationCheckoutRequest(
|
||||
string ProjectSlug,
|
||||
string QueryProjectSlug,
|
||||
string ItemSlug,
|
||||
int AmountInMinorUnits,
|
||||
string Currency,
|
||||
string Source);
|
||||
|
||||
public sealed record StripePatronageProjectPolicy(
|
||||
string ProjectSlug,
|
||||
string DisplayName,
|
||||
string Source,
|
||||
string Model,
|
||||
string Currency,
|
||||
int MinimumAmountInMinorUnits,
|
||||
int MaximumAmountInMinorUnits);
|
||||
|
||||
public sealed record StripeDonationCheckout(
|
||||
StripePatronageProjectPolicy Policy,
|
||||
Guid ProjectId,
|
||||
string ItemSlug,
|
||||
int AmountInMinorUnits,
|
||||
string Currency);
|
||||
|
||||
public sealed record StripeCheckoutResult(
|
||||
StripeCheckoutStatus Status,
|
||||
Uri? CheckoutUrl,
|
||||
string Message)
|
||||
string Message,
|
||||
StripeDonationCheckout? Donation = null)
|
||||
{
|
||||
public static StripeCheckoutResult Created(Uri checkoutUrl) =>
|
||||
new(StripeCheckoutStatus.Created, checkoutUrl, "Stripe checkout session created.");
|
||||
|
||||
public static StripeCheckoutResult Validated(StripeDonationCheckout donation) =>
|
||||
new(StripeCheckoutStatus.Created, null, "Stripe checkout request validated.", donation);
|
||||
|
||||
public static StripeCheckoutResult NotConfigured(string message) =>
|
||||
new(StripeCheckoutStatus.NotConfigured, null, message);
|
||||
|
||||
public static StripeCheckoutResult UnknownTier(string message) =>
|
||||
new(StripeCheckoutStatus.UnknownTier, null, message);
|
||||
public static StripeCheckoutResult InvalidRequest(string message) =>
|
||||
new(StripeCheckoutStatus.InvalidRequest, null, message);
|
||||
|
||||
public static StripeCheckoutResult UnknownProject(string message) =>
|
||||
new(StripeCheckoutStatus.UnknownProject, null, message);
|
||||
|
||||
public static StripeCheckoutResult ProviderError(string message) =>
|
||||
new(StripeCheckoutStatus.ProviderError, null, message);
|
||||
|
|
@ -139,6 +235,7 @@ public enum StripeCheckoutStatus
|
|||
{
|
||||
Created,
|
||||
NotConfigured,
|
||||
UnknownTier,
|
||||
InvalidRequest,
|
||||
UnknownProject,
|
||||
ProviderError
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ public sealed class StripeWebhookService(
|
|||
"Stripe checkout session is not linked to an unsuspended Bifrost patron account.");
|
||||
}
|
||||
|
||||
var projectId = await ResolveProjectIdAsync(session, cancellationToken);
|
||||
if (projectId.Status != StripeWebhookProjectStatus.Resolved)
|
||||
{
|
||||
return StripeWebhookResult.BadRequest(projectId.Message);
|
||||
}
|
||||
|
||||
var amount = session.AmountTotal.Value / 100m;
|
||||
var currencyCode = string.IsNullOrWhiteSpace(session.Currency)
|
||||
? "USD"
|
||||
|
|
@ -91,7 +97,8 @@ public sealed class StripeWebhookService(
|
|||
DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created),
|
||||
isCurrentRecurringSupport: false,
|
||||
BuildNotes(session),
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
projectId.ProjectId);
|
||||
|
||||
return StripeWebhookResult.Processed("Stripe patronage support event recorded.");
|
||||
}
|
||||
|
|
@ -120,10 +127,61 @@ public sealed class StripeWebhookService(
|
|||
return null;
|
||||
}
|
||||
|
||||
private async Task<StripeWebhookProjectResolution> ResolveProjectIdAsync(
|
||||
StripeCheckoutSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.Metadata.TryGetValue("project_id", out var projectIdText) &&
|
||||
Guid.TryParse(projectIdText, out var projectId))
|
||||
{
|
||||
var project = await dbContext.Projects
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.Id == projectId, cancellationToken);
|
||||
if (project is null || project.Status != ProjectStatus.Active)
|
||||
{
|
||||
return StripeWebhookProjectResolution.Invalid(
|
||||
$"Stripe checkout session references inactive or unknown project id '{projectIdText}'.");
|
||||
}
|
||||
|
||||
if (session.Metadata.TryGetValue("project", out var metadataProjectSlug) &&
|
||||
!string.IsNullOrWhiteSpace(metadataProjectSlug) &&
|
||||
!string.Equals(project.Slug, metadataProjectSlug.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeWebhookProjectResolution.Invalid(
|
||||
$"Stripe checkout session project slug '{metadataProjectSlug}' does not match project id '{projectIdText}'.");
|
||||
}
|
||||
|
||||
return StripeWebhookProjectResolution.Resolved(projectId);
|
||||
}
|
||||
|
||||
if (session.Metadata.TryGetValue("project", out var projectSlug) &&
|
||||
!string.IsNullOrWhiteSpace(projectSlug))
|
||||
{
|
||||
var normalizedSlug = projectSlug.Trim();
|
||||
var project = await dbContext.Projects
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Slug == normalizedSlug)
|
||||
.OrderBy(x => x.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return project is not null && project.Status == ProjectStatus.Active
|
||||
? StripeWebhookProjectResolution.Resolved(project.Id)
|
||||
: StripeWebhookProjectResolution.Invalid($"Stripe checkout session references inactive or unknown project '{normalizedSlug}'.");
|
||||
}
|
||||
|
||||
return StripeWebhookProjectResolution.Resolved(null);
|
||||
}
|
||||
|
||||
private static string BuildNotes(StripeCheckoutSession session)
|
||||
{
|
||||
session.Metadata.TryGetValue("project", out var project);
|
||||
session.Metadata.TryGetValue("item", out var item);
|
||||
session.Metadata.TryGetValue("tier", out var tier);
|
||||
session.Metadata.TryGetValue("source", out var source);
|
||||
if (!string.IsNullOrWhiteSpace(project) || !string.IsNullOrWhiteSpace(item))
|
||||
{
|
||||
return $"Verified Stripe checkout session for {source ?? "unknown source"} project {project ?? "unknown project"} item {item ?? "unknown item"}.";
|
||||
}
|
||||
|
||||
return $"Verified Stripe checkout session for {source ?? "unknown source"} tier {tier ?? "unknown tier"}.";
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +240,24 @@ public enum StripeWebhookStatus
|
|||
NotConfigured
|
||||
}
|
||||
|
||||
internal sealed record StripeWebhookProjectResolution(
|
||||
StripeWebhookProjectStatus Status,
|
||||
Guid? ProjectId,
|
||||
string Message)
|
||||
{
|
||||
public static StripeWebhookProjectResolution Resolved(Guid? projectId) =>
|
||||
new(StripeWebhookProjectStatus.Resolved, projectId, "Project resolved.");
|
||||
|
||||
public static StripeWebhookProjectResolution Invalid(string message) =>
|
||||
new(StripeWebhookProjectStatus.Invalid, null, message);
|
||||
}
|
||||
|
||||
internal enum StripeWebhookProjectStatus
|
||||
{
|
||||
Resolved,
|
||||
Invalid
|
||||
}
|
||||
|
||||
public sealed class StripeWebhookEvent
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
|
|
|||
|
|
@ -388,16 +388,27 @@ app.MapPost("/heimdall/patron-support/events", async (
|
|||
};
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/patronage/velvet/checkout", async (
|
||||
app.MapGet("/patronage/{project}/checkout", async (
|
||||
string project,
|
||||
HttpRequest request,
|
||||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
StripeCheckoutService stripeCheckoutService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tier = request.Query["tier"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tier))
|
||||
var amountCentsText = request.Query["amountCents"].ToString();
|
||||
if (!int.TryParse(amountCentsText, out var amountCents))
|
||||
{
|
||||
return Results.BadRequest("Missing required tier query parameter.");
|
||||
return Results.Text(
|
||||
"Missing or invalid required amountCents query parameter.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var item = request.Query["item"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(item))
|
||||
{
|
||||
return Results.Text(
|
||||
"Missing required item query parameter.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
|
|
@ -407,14 +418,25 @@ app.MapGet("/patronage/velvet/checkout", async (
|
|||
return Results.Redirect($"/auth/sign-in?returnUrl={returnUrl}");
|
||||
}
|
||||
|
||||
var result = await stripeCheckoutService.CreateVelvetCheckoutAsync(
|
||||
tier,
|
||||
var result = await stripeCheckoutService.CreateProjectDonationCheckoutAsync(
|
||||
new StripeDonationCheckoutRequest(
|
||||
project,
|
||||
request.Query["project"].ToString(),
|
||||
item,
|
||||
amountCents,
|
||||
request.Query["currency"].ToString(),
|
||||
request.Query["source"].ToString()),
|
||||
actor.UserAccount,
|
||||
cancellationToken);
|
||||
return result.Status switch
|
||||
{
|
||||
StripeCheckoutStatus.Created => Results.Redirect(result.CheckoutUrl!.ToString()),
|
||||
StripeCheckoutStatus.UnknownTier => Results.NotFound(result.Message),
|
||||
StripeCheckoutStatus.InvalidRequest => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status400BadRequest),
|
||||
StripeCheckoutStatus.UnknownProject => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status404NotFound),
|
||||
StripeCheckoutStatus.NotConfigured => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
|
|
|
|||
|
|
@ -371,8 +371,9 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
await EnsureVelvetProjectAsync(client);
|
||||
|
||||
using var response = await client.GetAsync("/patronage/velvet/checkout?tier=velvet-room");
|
||||
using var response = await client.GetAsync("/patronage/velvet/checkout?amountCents=1900&item=velvet-room&project=velvet");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("https://checkout.stripe.com/c/pay/cs_test_velvet", response.Headers.Location?.ToString());
|
||||
|
|
@ -385,24 +386,39 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
await EnsureVelvetProjectAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/patronage/velvet/checkout?tier=velvet-room");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/patronage/velvet/checkout?amountCents=1900&item=velvet-room&project=velvet");
|
||||
request.Headers.Add("X-Test-Anonymous", "1");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal(
|
||||
"/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3Ftier%3Dvelvet-room",
|
||||
"/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3FamountCents%3D1900%26item%3Dvelvet-room%26project%3Dvelvet",
|
||||
response.Headers.Location?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Velvet_patronage_checkout_rejects_out_of_policy_amount()
|
||||
{
|
||||
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
await EnsureVelvetProjectAsync(client);
|
||||
|
||||
using var response = await client.GetAsync("/patronage/velvet/checkout?amountCents=50&item=velvet-room&project=velvet");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_in_chooser_lists_supported_transport_providers()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3Ftier%3Dvelvet-room");
|
||||
using var response = await client.GetAsync("/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3FamountCents%3D1900%26item%3Dvelvet-room%26project%3Dvelvet");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
|
@ -418,10 +434,12 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
Guid patronageAccountId;
|
||||
Guid projectId;
|
||||
using (var setupScope = _factory.Services.CreateScope())
|
||||
{
|
||||
var setupContext = setupScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
patronageAccountId = setupContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN").Id;
|
||||
projectId = await EnsureVelvetProjectAsync(setupContext, patronageAccountId);
|
||||
}
|
||||
|
||||
var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
|
@ -444,8 +462,10 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
["source"] = "velvet.gamecult.org",
|
||||
["ledger"] = "bifrost",
|
||||
["purpose"] = "general_patronage",
|
||||
["project"] = "velvet",
|
||||
["project_id"] = projectId.ToString("N"),
|
||||
["item"] = "after-hours-bundle",
|
||||
["model"] = "deru",
|
||||
["tier"] = "after-hours-bundle",
|
||||
["bifrost_user_account_id"] = patronageAccountId.ToString("N"),
|
||||
["bifrost_github_login"] = "test-admin"
|
||||
}
|
||||
|
|
@ -468,8 +488,10 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
|
||||
Assert.Equal(patronageAccount.Id, supportEvent.UserAccountId);
|
||||
Assert.Equal("cs_test_velvet_paid", supportEvent.ExternalSupportId);
|
||||
Assert.Equal(projectId, supportEvent.ProjectId);
|
||||
Assert.Equal(39m, supportEvent.Amount);
|
||||
Assert.Equal("USD", supportEvent.CurrencyCode);
|
||||
Assert.Contains("velvet", supportEvent.Notes);
|
||||
Assert.Contains("after-hours-bundle", supportEvent.Notes);
|
||||
Assert.Contains(dbContext.AuditEvents, x => x.Action == "patron-support.recorded");
|
||||
}
|
||||
|
|
@ -1661,6 +1683,49 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
return $"t={timestamp},v1={signature}";
|
||||
}
|
||||
|
||||
private async Task<Guid> EnsureVelvetProjectAsync(HttpClient client)
|
||||
{
|
||||
_ = await client.GetAsync("/App");
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var owner = await dbContext.UserAccounts.SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN");
|
||||
return await EnsureVelvetProjectAsync(dbContext, owner.Id);
|
||||
}
|
||||
|
||||
private static async Task<Guid> EnsureVelvetProjectAsync(BifrostDbContext dbContext, Guid ownerUserAccountId)
|
||||
{
|
||||
var existing = await dbContext.Projects
|
||||
.Where(x => x.Slug == "velvet")
|
||||
.OrderBy(x => x.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status != ProjectStatus.Active)
|
||||
{
|
||||
existing.Status = ProjectStatus.Active;
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var project = new Project
|
||||
{
|
||||
OwnerUserAccountId = ownerUserAccountId,
|
||||
Slug = "velvet",
|
||||
Name = "Velvet",
|
||||
Summary = "General patronage for Velvet publication.",
|
||||
Status = ProjectStatus.Active,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
dbContext.Projects.Add(project);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return project.Id;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CreateSignedHeimdallRequest(string payload)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
|
||||
return new StripeCheckoutService(
|
||||
client,
|
||||
serviceProvider.GetRequiredService<BifrostDbContext>(),
|
||||
serviceProvider.GetRequiredService<IOptions<Bifrost.Web.Configuration.StripeOptions>>());
|
||||
});
|
||||
});
|
||||
|
|
@ -99,6 +100,8 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
var hasExpectedMetadata =
|
||||
form.Contains("metadata%5Bpurpose%5D=general_patronage", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bledger%5D=bifrost", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bproject%5D=velvet", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bitem%5D=velvet-room", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bbifrost_user_account_id%5D=", StringComparison.Ordinal);
|
||||
|
||||
if (request.RequestUri?.PathAndQuery != "/v1/checkout/sessions" || !hasExpectedMetadata)
|
||||
|
|
|
|||
Loading…
Reference in a new issue