Add project-scoped patronage checkout

This commit is contained in:
Meta Vens 2026-07-05 19:07:48 +01:00
commit bd3a5a974b
10 changed files with 396 additions and 56 deletions

View file

@ -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 =>

View file

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

View file

@ -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");

View file

@ -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

View file

@ -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,

View file

@ -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
}

View file

@ -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;

View file

@ -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),

View file

@ -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");

View file

@ -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)