From bb1b1056be427613fd9751f5da39e8ea39c1bddc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 9 Jun 2026 17:16:27 +0100 Subject: [PATCH] Add Heimdall patron support intake --- README.md | 7 +- docs/context.md | 2 + docs/full-implementation-strategy.md | 5 +- docs/jurisdiction-map.md | 10 ++ docs/paypal-patronage.md | 57 ++++++ .../Configuration/HeimdallOptions.cs | 9 + src/Bifrost.Web/Data/BifrostDbContext.cs | 7 + ...9160000_AddExternalPatronProviderIntake.cs | 75 ++++++++ .../BifrostDbContextModelSnapshot.cs | 23 +++ src/Bifrost.Web/Domain/BifrostModels.cs | 18 +- .../HeimdallPatronSupportIntakeService.cs | 166 ++++++++++++++++++ .../Features/Patronage/PatronTierPolicy.cs | 5 + .../Features/Patronage/PatronageService.cs | 46 ++++- .../Shared/StartupConfigurationValidator.cs | 7 + src/Bifrost.Web/Pages/App/Ledger/Index.cshtml | 1 + .../Pages/App/Ledger/Index.cshtml.cs | 8 +- src/Bifrost.Web/Program.cs | 24 +++ src/Bifrost.Web/appsettings.json | 4 +- .../PatronageServiceTests.cs | 4 + .../PlatformEndpointsTests.cs | 157 +++++++++++++++++ .../Support/TestWebApplicationFactory.cs | 3 +- 21 files changed, 625 insertions(+), 13 deletions(-) create mode 100644 docs/paypal-patronage.md create mode 100644 src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs diff --git a/README.md b/README.md index 0738258..36974ba 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - member-facing motions with category thresholds aligned to the labor-platform doc - health and readiness endpoints plus startup config validation - GitHub App webhook ingestion for issue, pull request, and review sync +- Heimdall-signed external patron support intake for Patreon and PayPal support facts - local bridge tooling for agent-owned GitHub draft PRs and Discord posts - local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app - CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion @@ -70,6 +71,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path - set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads +- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` - test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed @@ -90,5 +92,6 @@ Before implementation work starts in a new session, read these files in order: 5. [docs/verse-service-contract.md](E:\Projects\Bifrost\docs\verse-service-contract.md) 6. [docs/agent-transport.md](E:\Projects\Bifrost\docs\agent-transport.md) 7. [docs/reddit.md](E:\Projects\Bifrost\docs\reddit.md) -8. [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md) -9. [docs/context.md](E:\Projects\Bifrost\docs\context.md) +8. [docs/paypal-patronage.md](E:\Projects\Bifrost\docs\paypal-patronage.md) +9. [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md) +10. [docs/context.md](E:\Projects\Bifrost\docs\context.md) diff --git a/docs/context.md b/docs/context.md index 2fde91e..521e31a 100644 --- a/docs/context.md +++ b/docs/context.md @@ -73,5 +73,7 @@ After bootstrap, the next session should start implementing the first vertical s - a formal proposal that affects either project work or GameCult policy/structure - `Ledger Entry` - an immutable record of patron points, contributor points, nominal compensation, or adjustments +- `Patron Support Event` + - an auditable support fact recorded manually or through a Heimdall-verified external provider event; Bifrost derives patron points and voting tier snapshots from these events - `Payout Proposal Batch` - an admin-reviewed grouping of payout recommendations derived from approved ledger data diff --git a/docs/full-implementation-strategy.md b/docs/full-implementation-strategy.md index d674630..fa298cd 100644 --- a/docs/full-implementation-strategy.md +++ b/docs/full-implementation-strategy.md @@ -97,7 +97,10 @@ Implementation requirements: - calculate patron points from current recurring support plus historical donations: implemented by `PatronageService` - apply the concept-doc rule that historical donations are halved after one month and then continue decaying: implemented for effective patron point calculation - expose both raw support history and current effective patron points: implemented on the ledger surface -- ingest Patreon/Heimdall provider events into the same service path: pending +- ingest Patreon/Heimdall provider events into the same service path: implemented through `/heimdall/patron-support/events` +- ingest PayPal/Heimdall checkout, subscription, refund, reversal, and chargeback facts into the same service path: implemented as provider metadata plus support adjustments + +External provider support facts are not payment webhooks in Bifrost. Heimdall owns provider webhook verification and account linking. Bifrost accepts only Heimdall-signed support facts, resolves the linked `HeimdallAccountId`, deduplicates by provider event id, records support through `PatronageService`, and refreshes the derived patron tier. ### Contributor Points diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index 5f92178..8643ccf 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -85,6 +85,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut - canonical feature-request and governance topic threads - topic comments, support, objections, questions, Persona approvals, dispatch promotion, and topic receipts - user, patron, member, contributor, and agent transport events when they affect work, priority, reward, votes, claims, or receipts +- patron support meaning: support events, effective patron points, derived tier snapshots, voting weight, and audit trail - mapping from internal request to public target surface - GitHub proposal/comment/PR transport for agent and member work - Discord-native Bifrost commands/interactions, dispatch receipts, and persona announcements for work crossings and swarm I/O @@ -96,6 +97,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut ### Heimdall Owns - OAuth provider flows +- PayPal and Patreon webhook receipt, provider signature verification, payment-status normalization, and account-link evidence - account linking - credential custody - signed identity and capability claims @@ -103,6 +105,14 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. +### Patron Collection Crossing + +PayPal and Patreon money movement enters Bifrost only as Heimdall-verified support facts. Heimdall receives provider webhooks, verifies provider signatures, links the provider account to a Heimdall account, normalizes payment status, and POSTs a signed support fact to `/heimdall/patron-support/events`. + +Bifrost verifies Heimdall's intake HMAC and resolves the linked `HeimdallAccountId` to a Bifrost account. Only then does `PatronageService` record a `PatronSupportEvent`, point transaction, tier refresh, and audit event. Duplicate provider events are idempotent by `(Provider, ProviderEventId)`. + +PayPal one-time donation completions should arrive as `Provider = PayPal`, `Kind = OneTimeDonation`, and a positive amount after completed capture. PayPal subscription current-support snapshots should arrive as `Kind = RecurringSupportSnapshot` with `IsCurrentRecurringSupport = true` after Heimdall has verified active paid support. Refunds, reversals, and chargebacks should arrive as `Kind = SupportAdjustment` with a negative amount. Bifrost does not delete old support history to hide provider churn. + ### VoidBot Owns - Discord observation and room context diff --git a/docs/paypal-patronage.md b/docs/paypal-patronage.md new file mode 100644 index 0000000..e947698 --- /dev/null +++ b/docs/paypal-patronage.md @@ -0,0 +1,57 @@ +# PayPal Patronage Intake + +PayPal collection is a Heimdall-to-Bifrost crossing, not a Bifrost payment stack. + +## Authority + +- PayPal owns money movement, captures, subscription status, refunds, reversals, and chargebacks. +- Heimdall owns PayPal app credentials, webhook receipt, PayPal signature verification, provider account linking, and provider-event normalization. +- Bifrost owns patronage meaning: support events, point derivation, tier snapshots, voting weight, and audit records. + +## Intake Endpoint + +Heimdall POSTs verified support facts to: + +`POST /heimdall/patron-support/events` + +The raw request body must be signed with `HMACSHA256(Heimdall:PatronSupportIntakeSecret, body)` and sent as: + +`X-Heimdall-Signature-256: sha256=` + +Payload: + +```json +{ + "heimdallAccountId": "heimdall-account-id", + "provider": "PayPal", + "providerEventId": "paypal-webhook-event-id", + "kind": "OneTimeDonation", + "amount": 125.00, + "currencyCode": "USD", + "externalSupportId": "PAYMENT.CAPTURE.COMPLETED:CAPTURE-ID", + "supportedAtUtc": "2026-06-09T15:00:00Z", + "isCurrentRecurringSupport": false, + "providerPayerId": "PAYPAL-PAYER-ID", + "providerSubscriptionId": "", + "notes": "Verified PayPal checkout capture from Heimdall." +} +``` + +Valid `provider` values are `PayPal` and `Patreon`. `Manual` is rejected for this endpoint. + +Valid `kind` values: + +- `OneTimeDonation`: positive completed one-time support. +- `RecurringSupportSnapshot`: positive current recurring support; set `isCurrentRecurringSupport` to `true` when it is the current support amount. +- `SupportAdjustment`: negative refund, reversal, chargeback, or correction. + +`providerEventId` is the idempotency key inside a provider. Bifrost stores one support event per `(provider, providerEventId)`. + +## PayPal Event Mapping + +- Checkout/order donations: Heimdall records `OneTimeDonation` only after PayPal reports a completed capture. +- Subscription activation/current paid support: Heimdall records `RecurringSupportSnapshot` after verified active paid support. +- Subscription cancellation, suspension, expiration, or failed payment: Heimdall should send a new recurring snapshot that clears or reduces current support when paid support is no longer current. +- Refunds, reversals, and chargebacks: Heimdall records `SupportAdjustment` with a negative amount. + +Unlinked PayPal support must stay pending in Heimdall. Bifrost will not create voting power from a PayPal payer id alone. diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index 5c8b5b8..adddfdc 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -14,6 +14,10 @@ public sealed class HeimdallOptions public string PatreonTierTitle { get; init; } = "Inner Sanctum"; + public string PatronSupportIntakeSecret { get; init; } = string.Empty; + + public bool EnablePatronSupportIntake { get; init; } = true; + public bool IsConfigured => !string.IsNullOrWhiteSpace(BaseUrl) && !string.IsNullOrWhiteSpace(AppSlug); @@ -26,4 +30,9 @@ public sealed class HeimdallOptions public bool IsPatreonConfigured => IsConfigured && !string.IsNullOrWhiteSpace(PatreonTierTitle); + + public bool IsPatronSupportIntakeConfigured => + IsConfigured && + EnablePatronSupportIntake && + !string.IsNullOrWhiteSpace(PatronSupportIntakeSecret); } diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 7e5697d..d89bc7e 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -334,10 +334,17 @@ public sealed class BifrostDbContext(DbContextOptions options) modelBuilder.Entity(entity => { entity.Property(x => x.Kind).HasConversion(); + entity.Property(x => x.Provider).HasConversion(); entity.Property(x => x.Amount).HasPrecision(18, 2); entity.Property(x => x.ExternalSupportId).HasMaxLength(240); + entity.Property(x => x.ProviderEventId).HasMaxLength(240); + entity.Property(x => x.ProviderPayerId).HasMaxLength(240); + entity.Property(x => x.ProviderSubscriptionId).HasMaxLength(240); entity.Property(x => x.CurrencyCode).HasMaxLength(12); entity.HasIndex(x => new { x.UserAccountId, x.Kind, x.IsCurrentRecurringSupport }); + entity.HasIndex(x => new { x.Provider, x.ProviderEventId }) + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); entity.HasOne(x => x.UserAccount) .WithMany(x => x.PatronSupportEvents) .HasForeignKey(x => x.UserAccountId) diff --git a/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs b/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs new file mode 100644 index 0000000..172647c --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs @@ -0,0 +1,75 @@ +using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [Migration("20260609160000_AddExternalPatronProviderIntake")] + public partial class AddExternalPatronProviderIntake : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Provider", + table: "PatronSupportEvents", + type: "text", + nullable: false, + defaultValue: nameof(ExternalPatronProvider.Manual)); + + migrationBuilder.AddColumn( + name: "ProviderEventId", + table: "PatronSupportEvents", + type: "character varying(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ProviderPayerId", + table: "PatronSupportEvents", + type: "character varying(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ProviderSubscriptionId", + table: "PatronSupportEvents", + type: "character varying(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_PatronSupportEvents_Provider_ProviderEventId", + table: "PatronSupportEvents", + columns: new[] { "Provider", "ProviderEventId" }, + unique: true, + filter: "\"ProviderEventId\" <> ''"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PatronSupportEvents_Provider_ProviderEventId", + table: "PatronSupportEvents"); + + migrationBuilder.DropColumn( + name: "Provider", + table: "PatronSupportEvents"); + + migrationBuilder.DropColumn( + name: "ProviderEventId", + table: "PatronSupportEvents"); + + migrationBuilder.DropColumn( + name: "ProviderPayerId", + table: "PatronSupportEvents"); + + migrationBuilder.DropColumn( + name: "ProviderSubscriptionId", + table: "PatronSupportEvents"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 94fff04..aef12ad 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -500,6 +500,25 @@ namespace Bifrost.Web.Data.Migrations b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + b.Property("SupportedAtUtc") .HasColumnType("timestamp with time zone"); @@ -512,6 +531,10 @@ namespace Bifrost.Web.Data.Migrations b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + b.ToTable("PatronSupportEvents"); }); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index df80b7b..331852e 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -163,7 +163,15 @@ public enum PointTransactionType public enum PatronSupportEventKind { OneTimeDonation, - RecurringSupportSnapshot + RecurringSupportSnapshot, + SupportAdjustment +} + +public enum ExternalPatronProvider +{ + Manual, + Patreon, + PayPal } public sealed class UserAccount @@ -717,6 +725,14 @@ public sealed class PatronSupportEvent public string ExternalSupportId { get; set; } = string.Empty; + public ExternalPatronProvider Provider { get; set; } = ExternalPatronProvider.Manual; + + public string ProviderEventId { get; set; } = string.Empty; + + public string ProviderPayerId { get; set; } = string.Empty; + + public string ProviderSubscriptionId { get; set; } = string.Empty; + public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation; public decimal Amount { get; set; } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs new file mode 100644 index 0000000..2e95ff3 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -0,0 +1,166 @@ +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 Bifrost.Web.Features.Patronage; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Heimdall; + +public sealed class HeimdallPatronSupportIntakeService( + BifrostDbContext dbContext, + PatronageService patronageService, + IOptions heimdallOptions) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + public async Task ProcessAsync( + string signature, + string payload, + CancellationToken cancellationToken) + { + if (!heimdallOptions.Value.IsPatronSupportIntakeConfigured) + { + return HeimdallPatronSupportIntakeResult.NotConfigured("Heimdall patron support intake is not configured."); + } + + if (!VerifySignature(heimdallOptions.Value.PatronSupportIntakeSecret, payload, signature)) + { + return HeimdallPatronSupportIntakeResult.Unauthorized("Invalid Heimdall patron support intake signature."); + } + + var request = JsonSerializer.Deserialize(payload, JsonOptions); + if (request is null) + { + return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload was empty."); + } + + if (request.Provider is ExternalPatronProvider.Manual) + { + return HeimdallPatronSupportIntakeResult.BadRequest("Heimdall patron support intake requires an external provider."); + } + + if (string.IsNullOrWhiteSpace(request.HeimdallAccountId)) + { + return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload omitted Heimdall account id."); + } + + if (string.IsNullOrWhiteSpace(request.ProviderEventId)) + { + return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload omitted provider event id."); + } + + var existingEvent = await dbContext.PatronSupportEvents + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Provider == request.Provider && + x.ProviderEventId == request.ProviderEventId.Trim(), + cancellationToken); + + if (existingEvent is not null) + { + return HeimdallPatronSupportIntakeResult.Processed( + $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", + existingEvent.Id); + } + + var user = await dbContext.UserAccounts + .AsNoTracking() + .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); + + if (user is null) + { + return HeimdallPatronSupportIntakeResult.BadRequest( + $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); + } + + var supportEvent = await patronageService.RecordSupportEventAsync( + actorUserAccountId: null, + userAccountId: user.Id, + request.Kind, + request.Amount, + request.CurrencyCode, + request.ExternalSupportId, + request.Provider, + request.ProviderEventId, + request.ProviderPayerId, + request.ProviderSubscriptionId, + request.SupportedAtUtc, + request.IsCurrentRecurringSupport, + request.Notes, + cancellationToken); + + return HeimdallPatronSupportIntakeResult.Processed( + $"Processed {request.Provider} patron support event {request.ProviderEventId}.", + supportEvent.Id); + } + + private static bool VerifySignature(string secret, string payload, string signature) + { + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + var normalizedSignature = signature.Trim(); + if (normalizedSignature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase)) + { + normalizedSignature = normalizedSignature["sha256=".Length..]; + } + + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var expectedBytes = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload)); + var expectedHex = Convert.ToHexString(expectedBytes).ToLowerInvariant(); + + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(expectedHex), + Encoding.ASCII.GetBytes(normalizedSignature.ToLowerInvariant())); + } +} + +public sealed record HeimdallPatronSupportEventRequest( + string HeimdallAccountId, + ExternalPatronProvider Provider, + string ProviderEventId, + PatronSupportEventKind Kind, + decimal Amount, + string CurrencyCode, + string ExternalSupportId, + DateTimeOffset SupportedAtUtc, + bool IsCurrentRecurringSupport, + string ProviderPayerId = "", + string ProviderSubscriptionId = "", + string Notes = ""); + +public sealed record HeimdallPatronSupportIntakeResult( + HeimdallPatronSupportIntakeStatus Status, + string Message, + Guid? PatronSupportEventId) +{ + public static HeimdallPatronSupportIntakeResult Processed(string message, Guid patronSupportEventId) => + new(HeimdallPatronSupportIntakeStatus.Processed, message, patronSupportEventId); + + public static HeimdallPatronSupportIntakeResult BadRequest(string message) => + new(HeimdallPatronSupportIntakeStatus.BadRequest, message, null); + + public static HeimdallPatronSupportIntakeResult Unauthorized(string message) => + new(HeimdallPatronSupportIntakeStatus.Unauthorized, message, null); + + public static HeimdallPatronSupportIntakeResult NotConfigured(string message) => + new(HeimdallPatronSupportIntakeStatus.NotConfigured, message, null); +} + +public enum HeimdallPatronSupportIntakeStatus +{ + Processed, + BadRequest, + Unauthorized, + NotConfigured +} diff --git a/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs b/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs index ac197f5..a9be083 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs @@ -35,6 +35,11 @@ public static class PatronTierPolicy return Math.Max(0m, supportEvent.Amount); } + if (supportEvent.Kind == PatronSupportEventKind.SupportAdjustment) + { + return supportEvent.Amount; + } + var amount = Math.Max(0m, supportEvent.Amount); var age = nowUtc - supportEvent.SupportedAtUtc.ToUniversalTime(); if (age < TimeSpan.FromDays(30)) diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 05b6bd5..06d2b89 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -11,12 +11,16 @@ public sealed class PatronageService( TimeProvider timeProvider) { public async Task RecordSupportEventAsync( - Guid actorUserAccountId, + Guid? actorUserAccountId, Guid userAccountId, PatronSupportEventKind kind, decimal amount, string currencyCode, string externalSupportId, + ExternalPatronProvider provider, + string providerEventId, + string providerPayerId, + string providerSubscriptionId, DateTimeOffset supportedAtUtc, bool isCurrentRecurringSupport, string notes, @@ -31,11 +35,37 @@ public sealed class PatronageService( throw new InvalidOperationException("Patron support can only be recorded for an active member."); } - if (amount <= 0) + if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) { throw new InvalidOperationException("Patron support amount must be greater than zero."); } + if (amount == 0) + { + throw new InvalidOperationException("Patron support amount must not be zero."); + } + + if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0) + { + throw new InvalidOperationException("Patron support adjustments must reduce support."); + } + + var normalizedProviderEventId = providerEventId.Trim(); + if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId)) + { + throw new InvalidOperationException("External patron provider events require a provider event id."); + } + + var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId) + ? null + : await dbContext.PatronSupportEvents + .SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken); + + if (existingProviderEvent is not null) + { + return existingProviderEvent; + } + var now = timeProvider.GetUtcNow(); var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode) ? "USD" @@ -60,6 +90,10 @@ public sealed class PatronageService( UserAccountId = userAccountId, Kind = kind, ExternalSupportId = externalSupportId.Trim(), + Provider = provider, + ProviderEventId = normalizedProviderEventId, + ProviderPayerId = providerPayerId.Trim(), + ProviderSubscriptionId = providerSubscriptionId.Trim(), Amount = amount, CurrencyCode = normalizedCurrency, IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport, @@ -74,9 +108,9 @@ public sealed class PatronageService( UserAccountId = userAccountId, Type = PointTransactionType.PatronSupport, Amount = amount, - IsDecaying = kind != PatronSupportEventKind.RecurringSupportSnapshot, + IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, CreatedAtUtc = now, - Note = $"Recorded {kind} patron support event {supportEvent.ExternalSupportId}." + Note = $"Recorded {provider} {kind} patron support event {supportEvent.ExternalSupportId}." }); await dbContext.SaveChangesAsync(cancellationToken); @@ -85,7 +119,7 @@ public sealed class PatronageService( nameof(PatronSupportEvent), supportEvent.Id, "patron-support.recorded", - $"Recorded {amount:0.##} {normalizedCurrency} {kind} patron support for {user.DisplayName}.", + $"Recorded {amount:0.##} {normalizedCurrency} {provider} {kind} patron support for {user.DisplayName}.", cancellationToken); await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken); @@ -93,7 +127,7 @@ public sealed class PatronageService( } public async Task RefreshPatronTierSnapshotAsync( - Guid actorUserAccountId, + Guid? actorUserAccountId, Guid userAccountId, CancellationToken cancellationToken) { diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 49061f3..af028a4 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -8,6 +8,7 @@ public sealed class StartupConfigurationValidator( IHostEnvironment hostEnvironment, IOptions gitHubOAuthOptions, IOptions gitHubAppOptions, + IOptions heimdallOptions, IOptions hostOptions) { public void Validate() @@ -34,6 +35,12 @@ public sealed class StartupConfigurationValidator( failures.Add("Host:PublicBaseUrl and Host:ExpectedHost must be configured."); } + if (heimdallOptions.Value.EnablePatronSupportIntake && + !heimdallOptions.Value.IsPatronSupportIntakeConfigured) + { + failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled."); + } + var allowedHosts = configuration["AllowedHosts"] ?? string.Empty; if (hostOptions.Value.RequireStrictHostValidation && (string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*'))) diff --git a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml index 80520c7..b3ae5fd 100644 --- a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml @@ -206,6 +206,7 @@ @support.MemberName + @support.Provider @support.Kind
@support.Amount.ToString("0.##") @support.CurrencyCode @if (support.IsCurrentRecurringSupport) diff --git a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs index a121a16..9b6ad9e 100644 --- a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs @@ -111,6 +111,10 @@ public sealed class IndexModel( PatronSupport.Amount, PatronSupport.CurrencyCode, PatronSupport.ExternalSupportId ?? string.Empty, + ExternalPatronProvider.Manual, + string.Empty, + string.Empty, + string.Empty, PatronSupport.SupportedAtUtc, PatronSupport.IsCurrentRecurringSupport, PatronSupport.Notes ?? string.Empty, @@ -181,6 +185,7 @@ public sealed class IndexModel( return new PatronSupportListItem( x.UserAccountId, x.UserAccount.DisplayName, + x.Provider, x.Kind, x.Amount, x.CurrencyCode, @@ -244,7 +249,7 @@ public sealed class IndexModel( [Display(Name = "Support kind")] public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation; - [Range(0.01, 100000000)] + [Range(-100000000, 100000000)] public decimal Amount { get; set; } [Display(Name = "Currency")] @@ -270,6 +275,7 @@ public sealed class IndexModel( public sealed record PatronSupportListItem( Guid UserAccountId, string MemberName, + ExternalPatronProvider Provider, PatronSupportEventKind Kind, decimal Amount, string CurrencyCode, diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 9218471..f04d84a 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -70,6 +70,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -284,6 +285,29 @@ app.MapPost("/auth/heimdall/callback", ( } }).AllowAnonymous(); +app.MapPost("/heimdall/patron-support/events", async ( + HttpRequest request, + HeimdallPatronSupportIntakeService intakeService, + CancellationToken cancellationToken) => +{ + using var reader = new StreamReader(request.Body); + var payload = await reader.ReadToEndAsync(cancellationToken); + var signature = request.Headers["X-Heimdall-Signature-256"].ToString(); + + var result = await intakeService.ProcessAsync(signature, payload, cancellationToken); + return result.Status switch + { + HeimdallPatronSupportIntakeStatus.Processed => Results.Text( + result.Message, + statusCode: StatusCodes.Status202Accepted), + HeimdallPatronSupportIntakeStatus.BadRequest => Results.BadRequest(result.Message), + HeimdallPatronSupportIntakeStatus.NotConfigured => Results.Text( + result.Message, + statusCode: StatusCodes.Status503ServiceUnavailable), + _ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized) + }; +}).AllowAnonymous(); + app.MapGet("/auth/heimdall/wait", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index e8891aa..4041e7d 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -29,7 +29,9 @@ "AppSlug": "bifrost", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], - "PatreonTierTitle": "Inner Sanctum" + "PatreonTierTitle": "Inner Sanctum", + "PatronSupportIntakeSecret": "", + "EnablePatronSupportIntake": true }, "Logging": { "LogLevel": { diff --git a/tests/Bifrost.Web.Tests/PatronageServiceTests.cs b/tests/Bifrost.Web.Tests/PatronageServiceTests.cs index 1e1bfb3..071ec13 100644 --- a/tests/Bifrost.Web.Tests/PatronageServiceTests.cs +++ b/tests/Bifrost.Web.Tests/PatronageServiceTests.cs @@ -70,6 +70,10 @@ public sealed class PatronageServiceTests 125m, "usd", "patreon-member-1", + ExternalPatronProvider.Patreon, + "member-pledge-event-1", + "patreon-user-1", + "patreon-member-1", now, isCurrentRecurringSupport: true, "Imported from Patreon current support.", diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 009dfc5..3984610 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Security.Cryptography; using System.Text; +using System.Text.Json; +using Bifrost.Web.Features.Patronage; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; @@ -78,10 +80,165 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + var patron = new UserAccount + { + HeimdallAccountId = heimdallAccountId, + DisplayName = "PayPal Patron", + GitHubLogin = $"paypal-{Guid.NewGuid():N}", + NormalizedGitHubLogin = $"paypal-{Guid.NewGuid():N}", + CreatedAtUtc = now, + LastSeenAtUtc = now + }; + patronUserAccountId = patron.Id; + patron.Membership = new Membership + { + UserAccountId = patron.Id, + Status = MembershipStatus.Active, + CreatedAtUtc = now + }; + dbContext.UserAccounts.Add(patron); + await dbContext.SaveChangesAsync(); + } + + var payload = JsonSerializer.Serialize(new + { + heimdallAccountId, + provider = "PayPal", + providerEventId, + kind = "OneTimeDonation", + amount = 125m, + currencyCode = "USD", + externalSupportId = "PAYMENT.CAPTURE.COMPLETED:CAPTURE-1", + supportedAtUtc = now, + isCurrentRecurringSupport = false, + providerPayerId = "PAYER-1", + notes = "Verified PayPal checkout capture from Heimdall." + }); + + using var firstRequest = CreateSignedHeimdallRequest(payload); + using var firstResponse = await client.SendAsync(firstRequest); + using var secondRequest = CreateSignedHeimdallRequest(payload); + using var secondResponse = await client.SendAsync(secondRequest); + + Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.Accepted, secondResponse.StatusCode); + + using var verificationScope = _factory.Services.CreateScope(); + var verificationContext = verificationScope.ServiceProvider.GetRequiredService(); + var supportEvents = verificationContext.PatronSupportEvents + .Where(x => x.Provider == ExternalPatronProvider.PayPal && x.ProviderEventId == providerEventId) + .ToList(); + var tierSnapshot = verificationContext.TierSnapshots + .Single(x => x.Kind == TierSnapshotKind.Patron && + x.IsCurrent && + x.Membership.UserAccountId == patronUserAccountId); + + Assert.Single(supportEvents); + Assert.Equal("PAYMENT.CAPTURE.COMPLETED:CAPTURE-1", supportEvents.Single().ExternalSupportId); + Assert.Equal("Patron Silver", tierSnapshot.Label); + Assert.Equal(2m, tierSnapshot.Weight); + } + + [Fact] + public async Task Heimdall_paypal_adjustment_reduces_derived_patron_tier() + { + using var client = _factory.CreateClient(); + var now = DateTimeOffset.UtcNow; + var heimdallAccountId = $"heimdall-refund-{Guid.NewGuid():N}"; + Guid patronUserAccountId; + + using (var scope = _factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var patron = new UserAccount + { + HeimdallAccountId = heimdallAccountId, + DisplayName = "Refunded Patron", + GitHubLogin = $"refund-{Guid.NewGuid():N}", + NormalizedGitHubLogin = $"refund-{Guid.NewGuid():N}", + CreatedAtUtc = now, + LastSeenAtUtc = now + }; + patronUserAccountId = patron.Id; + patron.Membership = new Membership + { + UserAccountId = patron.Id, + Status = MembershipStatus.Active, + CreatedAtUtc = now + }; + dbContext.UserAccounts.Add(patron); + await dbContext.SaveChangesAsync(); + } + + var donationPayload = JsonSerializer.Serialize(new + { + heimdallAccountId, + provider = "PayPal", + providerEventId = $"WH-{Guid.NewGuid():N}", + kind = "OneTimeDonation", + amount = 125m, + currencyCode = "USD", + externalSupportId = "PAYMENT.CAPTURE.COMPLETED:CAPTURE-2", + supportedAtUtc = now, + isCurrentRecurringSupport = false + }); + var refundPayload = JsonSerializer.Serialize(new + { + heimdallAccountId, + provider = "PayPal", + providerEventId = $"WH-{Guid.NewGuid():N}", + kind = "SupportAdjustment", + amount = -125m, + currencyCode = "USD", + externalSupportId = "PAYMENT.CAPTURE.REFUNDED:CAPTURE-2", + supportedAtUtc = now.AddMinutes(1), + isCurrentRecurringSupport = false, + notes = "Verified PayPal refund from Heimdall." + }); + + using var donationRequest = CreateSignedHeimdallRequest(donationPayload); + using var donationResponse = await client.SendAsync(donationRequest); + using var refundRequest = CreateSignedHeimdallRequest(refundPayload); + using var refundResponse = await client.SendAsync(refundRequest); + + Assert.Equal(HttpStatusCode.Accepted, donationResponse.StatusCode); + Assert.Equal(HttpStatusCode.Accepted, refundResponse.StatusCode); + + using var verificationScope = _factory.Services.CreateScope(); + var verificationContext = verificationScope.ServiceProvider.GetRequiredService(); + var currentPatronTiers = verificationContext.TierSnapshots + .Where(x => x.Kind == TierSnapshotKind.Patron && + x.IsCurrent && + x.Membership.UserAccountId == patronUserAccountId) + .ToList(); + + Assert.Empty(currentPatronTiers); + } + private static string ComputeSignature(string secret, string payload) { using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload)); return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}"; } + + private static HttpRequestMessage CreateSignedHeimdallRequest(string payload) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events"); + request.Headers.Add("X-Heimdall-Signature-256", ComputeSignature("test-heimdall-intake-secret", payload)); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + return request; + } } diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index 571c90f..660d23f 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -32,7 +32,8 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory ["GitHubApp:AppId"] = "1", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", - ["Host:RequireStrictHostValidation"] = "false" + ["Host:RequireStrictHostValidation"] = "false", + ["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret" }); });