Add Heimdall patron support intake
This commit is contained in:
parent
ba09539561
commit
bb1b1056be
21 changed files with 625 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
docs/paypal-patronage.md
Normal file
57
docs/paypal-patronage.md
Normal file
|
|
@ -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=<hex>`
|
||||
|
||||
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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,10 +334,17 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
modelBuilder.Entity<PatronSupportEvent>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind).HasConversion<string>();
|
||||
entity.Property(x => x.Provider).HasConversion<string>();
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<string>(
|
||||
name: "Provider",
|
||||
table: "PatronSupportEvents",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: nameof(ExternalPatronProvider.Manual));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProviderEventId",
|
||||
table: "PatronSupportEvents",
|
||||
type: "character varying(240)",
|
||||
maxLength: 240,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProviderPayerId",
|
||||
table: "PatronSupportEvents",
|
||||
type: "character varying(240)",
|
||||
maxLength: 240,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -500,6 +500,25 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<DateTimeOffset>("RecordedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderEventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("ProviderPayerId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("ProviderSubscriptionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset>("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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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> heimdallOptions)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public async Task<HeimdallPatronSupportIntakeResult> 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<HeimdallPatronSupportEventRequest>(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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@ public sealed class PatronageService(
|
|||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<PatronSupportEvent> 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<PatronPointSummary> RefreshPatronTierSnapshotAsync(
|
||||
Guid actorUserAccountId,
|
||||
Guid? actorUserAccountId,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ public sealed class StartupConfigurationValidator(
|
|||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
|
||||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
IOptions<BifrostHostOptions> 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('*')))
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@
|
|||
<tr>
|
||||
<td>@support.MemberName</td>
|
||||
<td>
|
||||
<span class="pill">@support.Provider</span>
|
||||
<span class="pill">@support.Kind</span><br />
|
||||
@support.Amount.ToString("0.##") @support.CurrencyCode
|
||||
@if (support.IsCurrentRecurringSupport)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ builder.Services.AddScoped<DashboardSnapshotService>();
|
|||
builder.Services.AddScoped<MembershipSynchronizationService>();
|
||||
builder.Services.AddScoped<GitHubWebhookService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@
|
|||
"AppSlug": "bifrost",
|
||||
"DiscordGuildId": "",
|
||||
"DiscordAllowedRoleIds": [],
|
||||
"PatreonTierTitle": "Inner Sanctum"
|
||||
"PatreonTierTitle": "Inner Sanctum",
|
||||
"PatronSupportIntakeSecret": "",
|
||||
"EnablePatronSupportIntake": true
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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<TestWebApplicationFac
|
|||
Assert.Equal("GameCult/Bifrost".ToLowerInvariant(), project.GitHubRepository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Heimdall_paypal_support_event_records_patron_support_and_is_idempotent()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var heimdallAccountId = $"heimdall-paypal-{Guid.NewGuid():N}";
|
||||
var providerEventId = $"WH-{Guid.NewGuid():N}";
|
||||
Guid patronUserAccountId;
|
||||
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
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<BifrostDbContext>();
|
||||
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<BifrostDbContext>();
|
||||
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<BifrostDbContext>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["GitHubApp:AppId"] = "1",
|
||||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false"
|
||||
["Host:RequireStrictHostValidation"] = "false",
|
||||
["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret"
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue