Add Stripe patronage checkout integration
This commit is contained in:
parent
ba01b78d68
commit
861dff3ed7
13 changed files with 812 additions and 32 deletions
|
|
@ -71,6 +71,33 @@ Bifrost publishes under these CultMesh namespaces:
|
|||
Odin discovers Bifrost through the service namespace, then indexes the schema
|
||||
catalog and surface namespaces. Odin does not scrape Razor pages as truth.
|
||||
|
||||
## Codex MCP Boundary
|
||||
|
||||
Codex and similar external agents are xeno clients at the Verse boundary. The
|
||||
MCP surface that gives those clients access to Verse state belongs in Bifrost,
|
||||
not in an individual daemon package. Bifrost hosts the MCP because it owns the
|
||||
governed crossing: capability shaping, consent, policy, and the external
|
||||
protocol contract.
|
||||
|
||||
The MCP should expose Verse-level tools rather than daemon-specific shortcuts.
|
||||
Those tools ask Odin for overview and discovery, then route to known CultMesh
|
||||
addresses in the local Verse. Odin supplies the current map of daemons,
|
||||
surfaces, schemas, capabilities, and routes; Bifrost decides what a xeno client
|
||||
is allowed to see or command across that map.
|
||||
|
||||
Brokkr is one daemon that may be present in the local Verse. Its Unity editor
|
||||
TUI/Eve surface and CultCache mirror are discovered through Odin and addressed
|
||||
through CultMesh like any other resident service. Brokkr should not host the
|
||||
Codex MCP merely because it happens to be the daemon of interest for a given
|
||||
session.
|
||||
|
||||
Useful MCP tool shapes are therefore conceptual Verse tools such as
|
||||
`verse.overview`, `verse.daemons`, `verse.surfaces`, `verse.read`,
|
||||
`verse.write_intent`, and `verse.poll_events`. Their implementations may call
|
||||
Odin for discovery and then use CultLib/CultMesh/CultCache primitives to read or
|
||||
write the addressed service state. Local scripts may still inspect a Brokkr
|
||||
cache directly for debugging and development, but that is not the xeno boundary.
|
||||
|
||||
## Provider Advertisement Export
|
||||
|
||||
Bifrost has a read-only first-cut provider advertisement command:
|
||||
|
|
|
|||
28
src/Bifrost.Web/Configuration/StripeOptions.cs
Normal file
28
src/Bifrost.Web/Configuration/StripeOptions.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
public sealed class StripeOptions
|
||||
{
|
||||
public const string SectionName = "Stripe";
|
||||
|
||||
public bool EnableCheckout { get; set; }
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string WebhookSecret { get; set; } = string.Empty;
|
||||
|
||||
public string GeneralPatronageGitHubLogin { get; set; } = string.Empty;
|
||||
|
||||
public string SuccessUrl { get; set; } = "https://velvet.gamecult.org/?patronage=success";
|
||||
|
||||
public string CancelUrl { get; set; } = "https://velvet.gamecult.org/?patronage=cancelled";
|
||||
|
||||
public bool IsCheckoutConfigured =>
|
||||
EnableCheckout &&
|
||||
!string.IsNullOrWhiteSpace(SecretKey) &&
|
||||
!string.IsNullOrWhiteSpace(SuccessUrl) &&
|
||||
!string.IsNullOrWhiteSpace(CancelUrl);
|
||||
|
||||
public bool IsWebhookConfigured =>
|
||||
EnableCheckout &&
|
||||
!string.IsNullOrWhiteSpace(WebhookSecret);
|
||||
}
|
||||
|
|
@ -171,7 +171,8 @@ public enum ExternalPatronProvider
|
|||
{
|
||||
Manual,
|
||||
Patreon,
|
||||
PayPal
|
||||
PayPal,
|
||||
Stripe
|
||||
}
|
||||
|
||||
public enum BridgeActorKind
|
||||
|
|
|
|||
|
|
@ -24,13 +24,15 @@ public sealed class HeimdallAuthService(
|
|||
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"discord",
|
||||
"patreon"
|
||||
"patreon",
|
||||
"twitch"
|
||||
};
|
||||
|
||||
public async Task<HeimdallAuthStartResult> StartAsync(
|
||||
string provider,
|
||||
string callbackBaseUrl,
|
||||
string returnUrl,
|
||||
bool requireMemberAccess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AllowedProviders.Contains(provider))
|
||||
|
|
@ -44,9 +46,9 @@ public sealed class HeimdallAuthService(
|
|||
throw new HeimdallAuthException("Heimdall is not configured.");
|
||||
}
|
||||
|
||||
var entitlementPolicy = provider.Equals("discord", StringComparison.OrdinalIgnoreCase)
|
||||
? BuildDiscordPolicy(options)
|
||||
: BuildPatreonPolicy(options);
|
||||
var entitlementPolicy = requireMemberAccess
|
||||
? BuildMemberAccessPolicy(provider, options)
|
||||
: null;
|
||||
var attemptId = Guid.NewGuid().ToString("N");
|
||||
var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback");
|
||||
|
||||
|
|
@ -75,6 +77,7 @@ public sealed class HeimdallAuthService(
|
|||
}
|
||||
|
||||
attemptStore.Create(attemptId, returnUrl);
|
||||
attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess);
|
||||
return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId);
|
||||
}
|
||||
|
||||
|
|
@ -107,12 +110,12 @@ public sealed class HeimdallAuthService(
|
|||
throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed.");
|
||||
}
|
||||
|
||||
if (!payload.HasMemberAccess)
|
||||
if (attempt.RequiresMemberAccess && !payload.HasMemberAccess)
|
||||
{
|
||||
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
|
||||
}
|
||||
|
||||
var userAccount = await UpsertUserAccountAsync(payload, cancellationToken);
|
||||
var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, userAccount.GitHubUserId?.ToString() ?? userAccount.HeimdallAccountId),
|
||||
|
|
@ -137,6 +140,7 @@ public sealed class HeimdallAuthService(
|
|||
|
||||
private async Task<UserAccount> UpsertUserAccountAsync(
|
||||
HeimdallBackendCallbackPayload payload,
|
||||
bool grantMemberAccess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
|
@ -167,10 +171,12 @@ public sealed class HeimdallAuthService(
|
|||
},
|
||||
Membership = new Bifrost.Web.Domain.Membership
|
||||
{
|
||||
Status = MembershipStatus.Active,
|
||||
Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated,
|
||||
CreatedAtUtc = now,
|
||||
ApprovedAtUtc = now,
|
||||
Notes = "Activated by Heimdall member access claim"
|
||||
ApprovedAtUtc = grantMemberAccess ? now : null,
|
||||
Notes = grantMemberAccess
|
||||
? "Activated by Heimdall member access claim"
|
||||
: $"Identity verified through Heimdall {payload.Provider}"
|
||||
}
|
||||
};
|
||||
dbContext.UserAccounts.Add(userAccount);
|
||||
|
|
@ -191,18 +197,24 @@ public sealed class HeimdallAuthService(
|
|||
UserAccountId = userAccount.Id,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
userAccount.Membership.Status = MembershipStatus.Active;
|
||||
userAccount.Membership.ApprovedAtUtc ??= now;
|
||||
if (grantMemberAccess)
|
||||
{
|
||||
userAccount.Membership.Status = MembershipStatus.Active;
|
||||
userAccount.Membership.ApprovedAtUtc ??= now;
|
||||
}
|
||||
}
|
||||
|
||||
userAccount.MemberProfile!.Nickname = displayName;
|
||||
userAccount.MemberProfile.UpdatedAtUtc = now;
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
userAccount.Membership!,
|
||||
MemberRole.StandardMember,
|
||||
null,
|
||||
now,
|
||||
"Heimdall member access");
|
||||
if (grantMemberAccess)
|
||||
{
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
userAccount.Membership!,
|
||||
MemberRole.StandardMember,
|
||||
null,
|
||||
now,
|
||||
"Heimdall member access");
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return userAccount;
|
||||
|
|
@ -236,6 +248,21 @@ public sealed class HeimdallAuthService(
|
|||
options.PatreonTierTitle);
|
||||
}
|
||||
|
||||
private static HeimdallEntitlementPolicy BuildMemberAccessPolicy(string provider, HeimdallOptions options)
|
||||
{
|
||||
if (provider.Equals("discord", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildDiscordPolicy(options);
|
||||
}
|
||||
|
||||
if (provider.Equals("patreon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildPatreonPolicy(options);
|
||||
}
|
||||
|
||||
throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access.");
|
||||
}
|
||||
|
||||
private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl)
|
||||
{
|
||||
var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
|
||||
|
|
@ -262,6 +289,14 @@ public sealed class HeimdallAuthAttemptStore
|
|||
_attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null);
|
||||
}
|
||||
|
||||
public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess)
|
||||
{
|
||||
_attempts.AddOrUpdate(
|
||||
attemptId,
|
||||
_ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess),
|
||||
(_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess });
|
||||
}
|
||||
|
||||
public void Complete(string attemptId, HeimdallBackendCallbackPayload payload)
|
||||
{
|
||||
_attempts.AddOrUpdate(
|
||||
|
|
@ -276,14 +311,18 @@ public sealed class HeimdallAuthAttemptStore
|
|||
}
|
||||
}
|
||||
|
||||
public sealed record HeimdallAuthAttempt(string ReturnUrl, HeimdallBackendCallbackPayload? Payload);
|
||||
public sealed record HeimdallAuthAttempt(
|
||||
string ReturnUrl,
|
||||
HeimdallBackendCallbackPayload? Payload,
|
||||
bool RequiresMemberAccess = false);
|
||||
|
||||
public sealed record HeimdallStartRequest(
|
||||
[property: JsonPropertyName("appSlug")] string AppSlug,
|
||||
[property: JsonPropertyName("mode")] string Mode,
|
||||
[property: JsonPropertyName("returnTo")] string ReturnTo,
|
||||
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
|
||||
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy EntitlementPolicy);
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy);
|
||||
|
||||
public sealed record HeimdallHandoff(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ public sealed class PatronageService(
|
|||
.Include(x => x.Membership)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null || user.Membership.Status != MembershipStatus.Active)
|
||||
if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
throw new InvalidOperationException("Patron support can only be recorded for an active member.");
|
||||
throw new InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account.");
|
||||
}
|
||||
|
||||
if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment)
|
||||
|
|
|
|||
144
src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs
Normal file
144
src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Patronage;
|
||||
|
||||
public sealed class StripeCheckoutService(
|
||||
HttpClient httpClient,
|
||||
IOptions<StripeOptions> options)
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, StripePatronageTier> VelvetTiers =
|
||||
new Dictionary<string, StripePatronageTier>(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")
|
||||
};
|
||||
|
||||
public async Task<StripeCheckoutResult> CreateVelvetCheckoutAsync(
|
||||
string tierSlug,
|
||||
UserAccount patronAccount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stripeOptions = options.Value;
|
||||
if (!stripeOptions.IsCheckoutConfigured)
|
||||
{
|
||||
return StripeCheckoutResult.NotConfigured("Stripe checkout is not configured.");
|
||||
}
|
||||
|
||||
if (!VelvetTiers.TryGetValue(tierSlug.Trim(), out var tier))
|
||||
{
|
||||
return StripeCheckoutResult.UnknownTier($"Unknown Velvet patronage tier '{tierSlug}'.");
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StripeCheckoutResult.ProviderError(
|
||||
$"Stripe checkout session creation failed with {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var url = json.RootElement.TryGetProperty("url", out var urlElement)
|
||||
? urlElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return StripeCheckoutResult.ProviderError("Stripe checkout session response did not include a URL.");
|
||||
}
|
||||
|
||||
return StripeCheckoutResult.Created(new Uri(url));
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> BuildCheckoutForm(
|
||||
StripeOptions options,
|
||||
StripePatronageTier tier,
|
||||
UserAccount patronAccount)
|
||||
{
|
||||
var referenceId = $"velvet:{tier.Slug}:{patronAccount.Id:N}:{Guid.NewGuid():N}";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "velvet.gamecult.org",
|
||||
["umbrella"] = "GameCult",
|
||||
["ledger"] = "bifrost",
|
||||
["purpose"] = "general_patronage",
|
||||
["model"] = "deru",
|
||||
["tier"] = tier.Slug,
|
||||
["bifrost_user_account_id"] = patronAccount.Id.ToString("N"),
|
||||
["bifrost_github_login"] = patronAccount.GitHubLogin
|
||||
};
|
||||
|
||||
yield return new("mode", "payment");
|
||||
yield return new("success_url", options.SuccessUrl);
|
||||
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);
|
||||
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
yield return new($"metadata[{key}]", value);
|
||||
yield return new($"payment_intent_data[metadata][{key}]", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StripePatronageTier(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Description,
|
||||
int AmountInMinorUnits,
|
||||
string Currency);
|
||||
|
||||
public sealed record StripeCheckoutResult(
|
||||
StripeCheckoutStatus Status,
|
||||
Uri? CheckoutUrl,
|
||||
string Message)
|
||||
{
|
||||
public static StripeCheckoutResult Created(Uri checkoutUrl) =>
|
||||
new(StripeCheckoutStatus.Created, checkoutUrl, "Stripe checkout session created.");
|
||||
|
||||
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 ProviderError(string message) =>
|
||||
new(StripeCheckoutStatus.ProviderError, null, message);
|
||||
}
|
||||
|
||||
public enum StripeCheckoutStatus
|
||||
{
|
||||
Created,
|
||||
NotConfigured,
|
||||
UnknownTier,
|
||||
ProviderError
|
||||
}
|
||||
216
src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs
Normal file
216
src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
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 StripeWebhookService(
|
||||
BifrostDbContext dbContext,
|
||||
PatronageService patronageService,
|
||||
IOptions<StripeOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<StripeWebhookResult> ProcessAsync(
|
||||
string signatureHeader,
|
||||
string payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stripeOptions = options.Value;
|
||||
if (!stripeOptions.IsWebhookConfigured)
|
||||
{
|
||||
return StripeWebhookResult.NotConfigured("Stripe webhook processing is not configured.");
|
||||
}
|
||||
|
||||
if (!HasValidSignature(signatureHeader, payload, stripeOptions.WebhookSecret))
|
||||
{
|
||||
return StripeWebhookResult.Unauthorized("Invalid Stripe webhook signature.");
|
||||
}
|
||||
|
||||
StripeWebhookEvent? stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = JsonSerializer.Deserialize<StripeWebhookEvent>(payload, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return StripeWebhookResult.BadRequest("Stripe webhook payload is not valid JSON.");
|
||||
}
|
||||
|
||||
if (stripeEvent is null || string.IsNullOrWhiteSpace(stripeEvent.Id))
|
||||
{
|
||||
return StripeWebhookResult.BadRequest("Stripe webhook payload is missing an event id.");
|
||||
}
|
||||
|
||||
if (!string.Equals(stripeEvent.Type, "checkout.session.completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeWebhookResult.Ignored($"Ignored Stripe event type '{stripeEvent.Type}'.");
|
||||
}
|
||||
|
||||
var session = stripeEvent.Data.Object;
|
||||
if (!string.Equals(session.PaymentStatus, "paid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StripeWebhookResult.Ignored($"Ignored Stripe checkout session with payment status '{session.PaymentStatus}'.");
|
||||
}
|
||||
|
||||
if (session.AmountTotal is null or <= 0)
|
||||
{
|
||||
return StripeWebhookResult.BadRequest("Stripe checkout session is missing a positive amount_total.");
|
||||
}
|
||||
|
||||
var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken);
|
||||
|
||||
if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return StripeWebhookResult.NotConfigured(
|
||||
"Stripe checkout session is not linked to an unsuspended Bifrost patron account.");
|
||||
}
|
||||
|
||||
var amount = session.AmountTotal.Value / 100m;
|
||||
var currencyCode = string.IsNullOrWhiteSpace(session.Currency)
|
||||
? "USD"
|
||||
: session.Currency.ToUpperInvariant();
|
||||
|
||||
await patronageService.RecordSupportEventAsync(
|
||||
actorUserAccountId: null,
|
||||
patronageAccount.Id,
|
||||
PatronSupportEventKind.OneTimeDonation,
|
||||
amount,
|
||||
currencyCode,
|
||||
session.Id,
|
||||
ExternalPatronProvider.Stripe,
|
||||
stripeEvent.Id,
|
||||
session.Customer,
|
||||
string.Empty,
|
||||
DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created),
|
||||
isCurrentRecurringSupport: false,
|
||||
BuildNotes(session),
|
||||
cancellationToken);
|
||||
|
||||
return StripeWebhookResult.Processed("Stripe patronage support event recorded.");
|
||||
}
|
||||
|
||||
private async Task<UserAccount?> ResolvePatronageAccountAsync(
|
||||
StripeCheckoutSession session,
|
||||
StripeOptions stripeOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.Metadata.TryGetValue("bifrost_user_account_id", out var userAccountIdText) &&
|
||||
Guid.TryParse(userAccountIdText, out var userAccountId))
|
||||
{
|
||||
return await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stripeOptions.GeneralPatronageGitHubLogin))
|
||||
{
|
||||
var normalizedLogin = stripeOptions.GeneralPatronageGitHubLogin.Trim().ToUpperInvariant();
|
||||
return await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildNotes(StripeCheckoutSession session)
|
||||
{
|
||||
session.Metadata.TryGetValue("tier", out var tier);
|
||||
session.Metadata.TryGetValue("source", out var source);
|
||||
return $"Verified Stripe checkout session for {source ?? "unknown source"} tier {tier ?? "unknown tier"}.";
|
||||
}
|
||||
|
||||
private static bool HasValidSignature(string signatureHeader, string payload, string webhookSecret)
|
||||
{
|
||||
var pieces = signatureHeader
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(piece => piece.Split('=', 2))
|
||||
.Where(parts => parts.Length == 2)
|
||||
.GroupBy(parts => parts[0], parts => parts[1])
|
||||
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!pieces.TryGetValue("t", out var timestamps) ||
|
||||
!pieces.TryGetValue("v1", out var signatures) ||
|
||||
timestamps.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var signedPayload = $"{timestamps[0]}.{payload}";
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
|
||||
var expected = Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(signedPayload))).ToLowerInvariant();
|
||||
return signatures.Any(signature => FixedTimeEquals(signature, expected));
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string actual, string expected)
|
||||
{
|
||||
var actualBytes = Encoding.UTF8.GetBytes(actual);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expected);
|
||||
return actualBytes.Length == expectedBytes.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StripeWebhookResult(
|
||||
StripeWebhookStatus Status,
|
||||
string Message)
|
||||
{
|
||||
public static StripeWebhookResult Processed(string message) => new(StripeWebhookStatus.Processed, message);
|
||||
|
||||
public static StripeWebhookResult Ignored(string message) => new(StripeWebhookStatus.Ignored, message);
|
||||
|
||||
public static StripeWebhookResult BadRequest(string message) => new(StripeWebhookStatus.BadRequest, message);
|
||||
|
||||
public static StripeWebhookResult Unauthorized(string message) => new(StripeWebhookStatus.Unauthorized, message);
|
||||
|
||||
public static StripeWebhookResult NotConfigured(string message) => new(StripeWebhookStatus.NotConfigured, message);
|
||||
}
|
||||
|
||||
public enum StripeWebhookStatus
|
||||
{
|
||||
Processed,
|
||||
Ignored,
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
NotConfigured
|
||||
}
|
||||
|
||||
public sealed class StripeWebhookEvent
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public long Created { get; set; }
|
||||
|
||||
public StripeWebhookData Data { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StripeWebhookData
|
||||
{
|
||||
public StripeCheckoutSession Object { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StripeCheckoutSession
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Customer { get; set; } = string.Empty;
|
||||
|
||||
public string Currency { get; set; } = "usd";
|
||||
|
||||
[JsonPropertyName("amount_total")]
|
||||
public long? AmountTotal { get; set; }
|
||||
|
||||
[JsonPropertyName("payment_status")]
|
||||
public string PaymentStatus { get; set; } = string.Empty;
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ public sealed class StartupConfigurationValidator(
|
|||
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
|
||||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
IOptions<StripeOptions> stripeOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions)
|
||||
{
|
||||
public void Validate()
|
||||
|
|
@ -41,6 +42,17 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
|
||||
}
|
||||
|
||||
if (stripeOptions.Value.EnableCheckout)
|
||||
{
|
||||
if (!stripeOptions.Value.IsCheckoutConfigured)
|
||||
{
|
||||
failures.Add("Stripe:SecretKey, Stripe:SuccessUrl, and Stripe:CancelUrl are required when Stripe checkout is enabled.");
|
||||
}
|
||||
|
||||
// Stripe:GeneralPatronageGitHubLogin is only a legacy fallback for old sessions
|
||||
// that predate account-bound checkout metadata.
|
||||
}
|
||||
|
||||
var allowedHosts = configuration["AllowedHosts"] ?? string.Empty;
|
||||
if (hostOptions.Value.RequireStrictHostValidation &&
|
||||
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*')))
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
|
||||
<a class="button secondary" href="/auth/heimdall/discord">Sign in with Discord</a>
|
||||
<a class="button secondary" href="/auth/heimdall/patreon">Sign in with Patreon</a>
|
||||
<a class="button" href="/auth/sign-in">Sign in or create account</a>
|
||||
<a class="button secondary" href="/auth/heimdall/discord?access=member">Member access through Discord</a>
|
||||
<a class="button secondary" href="/auth/heimdall/patreon?access=member">Member access through Patreon</a>
|
||||
}
|
||||
|
||||
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ builder.Services
|
|||
.AddOptions<HeimdallOptions>()
|
||||
.BindConfiguration(HeimdallOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<StripeOptions>()
|
||||
.BindConfiguration(StripeOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<BridgeOptions>()
|
||||
.BindConfiguration(BridgeOptions.SectionName);
|
||||
|
|
@ -88,6 +92,7 @@ builder.Services.AddScoped<GitHubWebhookService>();
|
|||
builder.Services.AddScoped<MotionGovernanceService>();
|
||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
|
|
@ -99,6 +104,10 @@ builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
|||
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||
});
|
||||
builder.Services.AddHttpClient<StripeCheckoutService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.stripe.com/");
|
||||
});
|
||||
|
||||
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
|
||||
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
|
||||
|
|
@ -243,21 +252,34 @@ app.MapPost("/github/webhooks", async (
|
|||
|
||||
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
|
||||
{
|
||||
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(returnUrl))
|
||||
{
|
||||
returnUrl = "/App";
|
||||
}
|
||||
|
||||
var provider = httpContext.Request.Query["provider"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
await WriteSignInChooserAsync(httpContext, returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gitHubOptions.Value.IsConfigured)
|
||||
{
|
||||
httpContext.Response.Redirect("/?auth=github-not-configured");
|
||||
return;
|
||||
}
|
||||
|
||||
var redirectUri = httpContext.Request.Query["returnUrl"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(redirectUri))
|
||||
{
|
||||
redirectUri = "/App";
|
||||
}
|
||||
|
||||
await httpContext.ChallengeAsync(
|
||||
GitHubAuthenticationDefaults.AuthenticationScheme,
|
||||
new() { RedirectUri = redirectUri });
|
||||
new() { RedirectUri = returnUrl });
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/heimdall/{provider}", async (
|
||||
|
|
@ -275,10 +297,16 @@ app.MapGet("/auth/heimdall/{provider}", async (
|
|||
returnUrl = "/App";
|
||||
}
|
||||
|
||||
var requireMemberAccess = string.Equals(
|
||||
httpContext.Request.Query["access"].ToString(),
|
||||
"member",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var start = await heimdallAuthService.StartAsync(
|
||||
provider,
|
||||
hostOptions.Value.PublicBaseUrl,
|
||||
returnUrl,
|
||||
requireMemberAccess,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Redirect(start.AuthorizationUrl.ToString());
|
||||
|
|
@ -327,6 +355,66 @@ app.MapPost("/heimdall/patron-support/events", async (
|
|||
};
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/patronage/velvet/checkout", async (
|
||||
HttpRequest request,
|
||||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
StripeCheckoutService stripeCheckoutService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tier = request.Query["tier"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tier))
|
||||
{
|
||||
return Results.BadRequest("Missing required tier query parameter.");
|
||||
}
|
||||
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString($"{request.Path}{request.QueryString}");
|
||||
return Results.Redirect($"/auth/sign-in?returnUrl={returnUrl}");
|
||||
}
|
||||
|
||||
var result = await stripeCheckoutService.CreateVelvetCheckoutAsync(
|
||||
tier,
|
||||
actor.UserAccount,
|
||||
cancellationToken);
|
||||
return result.Status switch
|
||||
{
|
||||
StripeCheckoutStatus.Created => Results.Redirect(result.CheckoutUrl!.ToString()),
|
||||
StripeCheckoutStatus.UnknownTier => Results.NotFound(result.Message),
|
||||
StripeCheckoutStatus.NotConfigured => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
_ => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status502BadGateway)
|
||||
};
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/patronage/stripe/webhook", async (
|
||||
HttpRequest request,
|
||||
StripeWebhookService webhookService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
var signature = request.Headers["Stripe-Signature"].ToString();
|
||||
|
||||
var result = await webhookService.ProcessAsync(signature, payload, cancellationToken);
|
||||
return result.Status switch
|
||||
{
|
||||
StripeWebhookStatus.Processed => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status202Accepted),
|
||||
StripeWebhookStatus.Ignored => Results.Text(result.Message),
|
||||
StripeWebhookStatus.BadRequest => Results.BadRequest(result.Message),
|
||||
StripeWebhookStatus.NotConfigured => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
_ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized)
|
||||
};
|
||||
}).AllowAnonymous();
|
||||
|
||||
var bridgeActions = app.MapGroup("/bridge/actions");
|
||||
|
||||
bridgeActions.MapPost("/request", async (
|
||||
|
|
@ -735,6 +823,47 @@ static IResult JsonResult<T>(T value, JsonSerializerOptions options, int statusC
|
|||
"application/json",
|
||||
statusCode: statusCode);
|
||||
|
||||
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
|
||||
{
|
||||
httpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
|
||||
var escapedReturnUrl = System.Net.WebUtility.HtmlEncode(returnUrl);
|
||||
await httpContext.Response.WriteAsync($$"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sign in - Bifrost</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; }
|
||||
main { width: min(92vw, 520px); padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
|
||||
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1; }
|
||||
p { color: #c7b8ad; }
|
||||
.providers { display: grid; gap: 10px; margin-top: 22px; }
|
||||
a { display: flex; justify-content: space-between; align-items: center; min-height: 46px; padding: 0 14px; border-radius: 6px; background: #c43e4f; color: white; text-decoration: none; font-weight: 750; }
|
||||
a.secondary { background: rgba(255,248,239,.08); border: 1px solid rgba(255,248,239,.22); }
|
||||
small { color: #d9ad66; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<small>Transport identity</small>
|
||||
<h1>Create or use a Bifrost patron account</h1>
|
||||
<p>Choose a provider to attach this checkout to a Bifrost account. Patron accounts do not automatically grant active member access.</p>
|
||||
<div class="providers">
|
||||
<a href="/auth/sign-in?provider=github&returnUrl={{encodedReturnUrl}}">GitHub <span>OAuth</span></a>
|
||||
<a class="secondary" href="/auth/heimdall/discord?returnUrl={{encodedReturnUrl}}">Discord <span>Heimdall</span></a>
|
||||
<a class="secondary" href="/auth/heimdall/patreon?returnUrl={{encodedReturnUrl}}">Patreon <span>Heimdall</span></a>
|
||||
<a class="secondary" href="/auth/heimdall/twitch?returnUrl={{encodedReturnUrl}}">Twitch <span>Heimdall</span></a>
|
||||
</div>
|
||||
<p><small>After sign-in, Bifrost returns you to {{escapedReturnUrl}}.</small></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
static JsonSerializerOptions CreateAppJsonSerializerOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@
|
|||
"PatronSupportIntakeSecret": "",
|
||||
"EnablePatronSupportIntake": true
|
||||
},
|
||||
"Stripe": {
|
||||
"EnableCheckout": false,
|
||||
"SecretKey": "",
|
||||
"WebhookSecret": "",
|
||||
"GeneralPatronageGitHubLogin": "",
|
||||
"SuccessUrl": "https://velvet.gamecult.org/?patronage=success",
|
||||
"CancelUrl": "https://velvet.gamecult.org/?patronage=cancelled"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using Bifrost.Web.Features.Patronage;
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
|
@ -228,6 +229,116 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
Assert.Empty(currentPatronTiers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Velvet_patronage_checkout_redirects_to_stripe_checkout()
|
||||
{
|
||||
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
using var response = await client.GetAsync("/patronage/velvet/checkout?tier=velvet-room");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("https://checkout.stripe.com/c/pay/cs_test_velvet", response.Headers.Location?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Velvet_patronage_checkout_requires_bifrost_account()
|
||||
{
|
||||
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/patronage/velvet/checkout?tier=velvet-room");
|
||||
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",
|
||||
response.Headers.Location?.ToString());
|
||||
}
|
||||
|
||||
[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");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("GitHub", html);
|
||||
Assert.Contains("/auth/heimdall/discord", html);
|
||||
Assert.Contains("/auth/heimdall/patreon", html);
|
||||
Assert.Contains("/auth/heimdall/twitch", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stripe_checkout_completed_webhook_records_general_patronage()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
Guid patronageAccountId;
|
||||
using (var setupScope = _factory.Services.CreateScope())
|
||||
{
|
||||
var setupContext = setupScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
patronageAccountId = setupContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN").Id;
|
||||
}
|
||||
|
||||
var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = $"evt_{Guid.NewGuid():N}",
|
||||
type = "checkout.session.completed",
|
||||
created,
|
||||
data = new
|
||||
{
|
||||
@object = new
|
||||
{
|
||||
id = "cs_test_velvet_paid",
|
||||
customer = "cus_velvet_1",
|
||||
currency = "usd",
|
||||
amount_total = 3900,
|
||||
payment_status = "paid",
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "velvet.gamecult.org",
|
||||
["ledger"] = "bifrost",
|
||||
["purpose"] = "general_patronage",
|
||||
["model"] = "deru",
|
||||
["tier"] = "after-hours-bundle",
|
||||
["bifrost_user_account_id"] = patronageAccountId.ToString("N"),
|
||||
["bifrost_github_login"] = "test-admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/patronage/stripe/webhook");
|
||||
request.Headers.Add("Stripe-Signature", ComputeStripeSignature("whsec_test_webhook_secret", payload));
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var patronageAccount = dbContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN");
|
||||
var supportEvent = dbContext.PatronSupportEvents.Single(x => x.Provider == ExternalPatronProvider.Stripe);
|
||||
|
||||
Assert.Equal(patronageAccount.Id, supportEvent.UserAccountId);
|
||||
Assert.Equal("cs_test_velvet_paid", supportEvent.ExternalSupportId);
|
||||
Assert.Equal(39m, supportEvent.Amount);
|
||||
Assert.Equal("USD", supportEvent.CurrencyCode);
|
||||
Assert.Contains("after-hours-bundle", supportEvent.Notes);
|
||||
Assert.Contains(dbContext.AuditEvents, x => x.Action == "patron-support.recorded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eve_governance_surface_returns_motion_verse()
|
||||
{
|
||||
|
|
@ -1021,6 +1132,16 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeStripeSignature(string secret, string payload)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var signature = Convert.ToHexString(
|
||||
hasher.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{payload}"))).ToLowerInvariant();
|
||||
|
||||
return $"t={timestamp},v1={signature}";
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CreateSignedHeimdallRequest(string payload)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
|
@ -8,6 +9,8 @@ using Microsoft.EntityFrameworkCore.Storage;
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
|
|
@ -34,7 +37,13 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false",
|
||||
["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret"
|
||||
["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret",
|
||||
["Stripe:EnableCheckout"] = "true",
|
||||
["Stripe:SecretKey"] = "sk_test_configured",
|
||||
["Stripe:WebhookSecret"] = "whsec_test_webhook_secret",
|
||||
["Stripe:GeneralPatronageGitHubLogin"] = "test-admin",
|
||||
["Stripe:SuccessUrl"] = "https://velvet.gamecult.org/?patronage=success",
|
||||
["Stripe:CancelUrl"] = "https://velvet.gamecult.org/?patronage=cancelled"
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -64,6 +73,52 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
options.LoginPath = "/auth/sign-in";
|
||||
options.AccessDeniedPath = "/Membership/Status";
|
||||
});
|
||||
|
||||
services.RemoveAll<StripeCheckoutService>();
|
||||
services.AddTransient(serviceProvider =>
|
||||
{
|
||||
var client = new HttpClient(new TestStripeCheckoutHandler())
|
||||
{
|
||||
BaseAddress = new Uri("https://api.stripe.test/")
|
||||
};
|
||||
|
||||
return new StripeCheckoutService(
|
||||
client,
|
||||
serviceProvider.GetRequiredService<IOptions<Bifrost.Web.Configuration.StripeOptions>>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestStripeCheckoutHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var form = await request.Content!.ReadAsStringAsync(cancellationToken);
|
||||
var hasExpectedMetadata =
|
||||
form.Contains("metadata%5Bpurpose%5D=general_patronage", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bledger%5D=bifrost", StringComparison.Ordinal) &&
|
||||
form.Contains("metadata%5Bbifrost_user_account_id%5D=", StringComparison.Ordinal);
|
||||
|
||||
if (request.RequestUri?.PathAndQuery != "/v1/checkout/sessions" || !hasExpectedMetadata)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent("{}")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{
|
||||
"id": "cs_test_velvet",
|
||||
"url": "https://checkout.stripe.com/c/pay/cs_test_velvet"
|
||||
}
|
||||
""")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue