From ba09539561b607bc0fb81ca3a419505b3b494ea5 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Tue, 9 Jun 2026 16:28:30 +0100
Subject: [PATCH] Add patron support derived voting tiers
---
README.md | 3 +-
docs/bifrost-mvp-plan.md | 2 +
docs/full-implementation-strategy.md | 14 +-
docs/reddit.md | 2 +-
src/Bifrost.Web/Data/BifrostDbContext.cs | 2 +
...260609143000_AddPatronSupportDerivation.cs | 50 +++++
.../BifrostDbContextModelSnapshot.cs | 9 +
src/Bifrost.Web/Domain/BifrostModels.cs | 10 +
.../Features/Patronage/PatronTierPolicy.cs | 54 +++++
.../Features/Patronage/PatronageService.cs | 186 ++++++++++++++++++
src/Bifrost.Web/Pages/App/Ledger/Index.cshtml | 137 ++++++++++++-
.../Pages/App/Ledger/Index.cshtml.cs | 129 +++++++++++-
.../Pages/App/Members/Index.cshtml | 8 +-
src/Bifrost.Web/Program.cs | 2 +
.../PatronageServiceTests.cs | 102 ++++++++++
15 files changed, 695 insertions(+), 15 deletions(-)
create mode 100644 src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs
create mode 100644 src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs
create mode 100644 src/Bifrost.Web/Features/Patronage/PatronageService.cs
create mode 100644 tests/Bifrost.Web.Tests/PatronageServiceTests.cs
diff --git a/README.md b/README.md
index 2f55e75..0738258 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
- GitHub OAuth sign-in plus invite/approval-based membership gating
- explicit member roles and admin-managed patron or contributor tier snapshots
+- patron support event recording with derived patron point summaries and tier refresh for voting weight
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
- planned priority/reward pressure loop where demand can raise a work item's value before a contributor claims it
- member-facing motions with category thresholds aligned to the labor-platform doc
@@ -43,7 +44,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m
- GitHub sign-in only
- GitHub-backed and internal work items
- app-native motions and voting
-- internal ledgers for patronage, contribution, and payout eligibility
+- internal ledgers for patronage, contribution, point evidence, and payout eligibility
- no real payout execution in v1
## Hosting Target
diff --git a/docs/bifrost-mvp-plan.md b/docs/bifrost-mvp-plan.md
index 319b3a6..0daf214 100644
--- a/docs/bifrost-mvp-plan.md
+++ b/docs/bifrost-mvp-plan.md
@@ -35,6 +35,7 @@
- `Motion`: `Management` or `Project`
- `Vote`: per-member vote tied to effective voting weight
- `LedgerEntry`: immutable patron/contributor/accounting record
+- `PatronSupportEvent`: auditable support fact used to derive patron points and tier snapshots
- `PayoutProposalBatch`: admin-reviewed payout recommendation batch only
- `AuditEvent`: append-only activity and override log
@@ -48,6 +49,7 @@
- Work priority and attached reward should be able to rise as more eligible participants want the work done.
- Contributor credit/reward is assigned only after maintainer acceptance of the completion artifact.
- Voting weight derives from patron tier plus contributor tier.
+- Patron tier can be refreshed from support events through the patronage service; manual tier entry is an override path.
- Reddit comments, upvotes, flair, and thread visibility do not derive voting weight; linked patron support and contribution state do.
- Compensation is modeled internally, not executed by the platform.
- Human approval remains in the loop for governance-sensitive and payout-sensitive steps.
diff --git a/docs/full-implementation-strategy.md b/docs/full-implementation-strategy.md
index 889836c..d674630 100644
--- a/docs/full-implementation-strategy.md
+++ b/docs/full-implementation-strategy.md
@@ -18,6 +18,7 @@ It is currently a strong first application slice, not an internet-facing alpha.
- invite and approval gate with explicit member roles
- member profile editing plus patron or contributor tier snapshots
- project, work item, motion, member, and ledger UI
+- patron support event recording with derived patron tier snapshot refresh
- work logs, work reviews, and completion flow
- GitHub App webhook ingestion for issue, pull request, and review sync
- health and readiness endpoints plus request logging
@@ -31,7 +32,7 @@ It is currently a strong first application slice, not an internet-facing alpha.
- backup and restore rehearsal for PostgreSQL and app secrets
- fuller workflow polish around blockers, archival, and review ergonomics
- production-safe payout proposal batching and finance review workflow
-- contribution point rules, patron point rules, decay jobs, and revenue share calculation
+- external patron provider ingestion, full contribution point rules, scheduled decay jobs, and revenue share calculation
## Product Alignment With Labor Platform
@@ -88,14 +89,15 @@ These concepts need a proper implementation strategy rather than a single `Point
### Patron Points
-Patron points should be derived from support events and support state, not edited freehand as a main workflow.
+Patron points are now derived from support events and support state, not edited freehand as a main workflow. Manual patron tier edits remain an admin override/fallback, not the normal voting-power owner.
Implementation requirements:
-- store recurring support state and historical donation events separately
-- calculate patron points from current recurring support plus historical donations
-- apply the concept-doc rule that historical donations are halved after one month and then continue decaying
-- expose both raw support history and current effective patron points
+- store recurring support state and historical donation events separately: implemented as `PatronSupportEventKind.RecurringSupportSnapshot` and `OneTimeDonation`
+- 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
### Contributor Points
diff --git a/docs/reddit.md b/docs/reddit.md
index 552f2f2..18402a1 100644
--- a/docs/reddit.md
+++ b/docs/reddit.md
@@ -20,7 +20,7 @@ Patron voting power should be prepared from Bifrost-owned account and support st
- Heimdall links external provider accounts to a GameCult account.
- Bifrost records patron support events and current recurring support state.
-- Bifrost derives effective patron points, decay, tier snapshots, and current voting weight.
+- Bifrost derives effective patron points and patron tier snapshots through `PatronageService`; scheduled decay jobs remain future work.
- Motions snapshot the effective voting weight when a linked actor votes.
Reddit can invite, explain, and collect discussion. It does not grant weight by itself. When a Reddit user should cast a vote or priority signal, the actor must be linked and the vote must be written into Bifrost.
diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs
index 670adba..7e5697d 100644
--- a/src/Bifrost.Web/Data/BifrostDbContext.cs
+++ b/src/Bifrost.Web/Data/BifrostDbContext.cs
@@ -333,9 +333,11 @@ public sealed class BifrostDbContext(DbContextOptions options)
modelBuilder.Entity(entity =>
{
+ entity.Property(x => x.Kind).HasConversion();
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
+ entity.HasIndex(x => new { x.UserAccountId, x.Kind, x.IsCurrentRecurringSupport });
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.PatronSupportEvents)
.HasForeignKey(x => x.UserAccountId)
diff --git a/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs b/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs
new file mode 100644
index 0000000..f32aa95
--- /dev/null
+++ b/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Bifrost.Web.Data.Migrations
+{
+ ///
+ [Migration("20260609143000_AddPatronSupportDerivation")]
+ public partial class AddPatronSupportDerivation : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "IsCurrentRecurringSupport",
+ table: "PatronSupportEvents",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "Kind",
+ table: "PatronSupportEvents",
+ type: "text",
+ nullable: false,
+ defaultValue: "OneTimeDonation");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PatronSupportEvents_UserAccountId_Kind_IsCurrentRecurringSupport",
+ table: "PatronSupportEvents",
+ columns: new[] { "UserAccountId", "Kind", "IsCurrentRecurringSupport" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_PatronSupportEvents_UserAccountId_Kind_IsCurrentRecurringSupport",
+ table: "PatronSupportEvents");
+
+ migrationBuilder.DropColumn(
+ name: "IsCurrentRecurringSupport",
+ table: "PatronSupportEvents");
+
+ migrationBuilder.DropColumn(
+ name: "Kind",
+ table: "PatronSupportEvents");
+ }
+ }
+}
diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
index 7fff467..94fff04 100644
--- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
+++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
@@ -486,6 +486,13 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
+ b.Property("IsCurrentRecurringSupport")
+ .HasColumnType("boolean");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasColumnType("text");
+
b.Property("Notes")
.IsRequired()
.HasColumnType("text");
@@ -503,6 +510,8 @@ namespace Bifrost.Web.Data.Migrations
b.HasIndex("UserAccountId");
+ b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
+
b.ToTable("PatronSupportEvents");
});
diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs
index 5c07334..df80b7b 100644
--- a/src/Bifrost.Web/Domain/BifrostModels.cs
+++ b/src/Bifrost.Web/Domain/BifrostModels.cs
@@ -160,6 +160,12 @@ public enum PointTransactionType
Decay
}
+public enum PatronSupportEventKind
+{
+ OneTimeDonation,
+ RecurringSupportSnapshot
+}
+
public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -711,10 +717,14 @@ public sealed class PatronSupportEvent
public string ExternalSupportId { get; set; } = string.Empty;
+ public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation;
+
public decimal Amount { get; set; }
public string CurrencyCode { get; set; } = "USD";
+ public bool IsCurrentRecurringSupport { get; set; }
+
public DateTimeOffset SupportedAtUtc { get; set; }
public DateTimeOffset RecordedAtUtc { get; set; }
diff --git a/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs b/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs
new file mode 100644
index 0000000..ac197f5
--- /dev/null
+++ b/src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs
@@ -0,0 +1,54 @@
+using Bifrost.Web.Domain;
+
+namespace Bifrost.Web.Features.Patronage;
+
+public sealed record PatronTierDefinition(string Label, decimal MinimumPoints, decimal VotingWeight);
+
+public static class PatronTierPolicy
+{
+ public static IReadOnlyList Tiers { get; } =
+ [
+ new("Bronze", 10m, 1m),
+ new("Silver", 100m, 2m),
+ new("Gold", 1_000m, 3m),
+ new("Platinum", 10_000m, 4m),
+ new("Unobtanium", 100_000m, 5m)
+ ];
+
+ public static PatronTierDefinition? ResolveTier(decimal effectivePoints) =>
+ Tiers
+ .Where(x => effectivePoints >= x.MinimumPoints)
+ .OrderByDescending(x => x.MinimumPoints)
+ .FirstOrDefault();
+
+ public static string SnapshotNote(decimal effectivePoints, DateTimeOffset calculatedAtUtc) =>
+ $"Derived from patron support events: {effectivePoints:0.##} effective patron points at {calculatedAtUtc:O}.";
+
+ public static string SnapshotLabel(PatronTierDefinition tier) =>
+ $"Patron {tier.Label}";
+
+ public static decimal GetHistoricalDonationPoints(PatronSupportEvent supportEvent, DateTimeOffset nowUtc)
+ {
+ if (supportEvent.Kind == PatronSupportEventKind.RecurringSupportSnapshot &&
+ supportEvent.IsCurrentRecurringSupport)
+ {
+ return Math.Max(0m, supportEvent.Amount);
+ }
+
+ var amount = Math.Max(0m, supportEvent.Amount);
+ var age = nowUtc - supportEvent.SupportedAtUtc.ToUniversalTime();
+ if (age < TimeSpan.FromDays(30))
+ {
+ return amount;
+ }
+
+ var weeksAfterFirstMonth = Math.Max(0, (int)Math.Floor((age.TotalDays - 30) / 7));
+ var decayed = amount * 0.5m;
+ for (var index = 0; index < weeksAfterFirstMonth; index += 1)
+ {
+ decayed *= 0.99m;
+ }
+
+ return Math.Floor(decayed);
+ }
+}
diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs
new file mode 100644
index 0000000..05b6bd5
--- /dev/null
+++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs
@@ -0,0 +1,186 @@
+using Bifrost.Web.Data;
+using Bifrost.Web.Domain;
+using Bifrost.Web.Features.Shared;
+using Microsoft.EntityFrameworkCore;
+
+namespace Bifrost.Web.Features.Patronage;
+
+public sealed class PatronageService(
+ BifrostDbContext dbContext,
+ AuditTrailService auditTrailService,
+ TimeProvider timeProvider)
+{
+ public async Task RecordSupportEventAsync(
+ Guid actorUserAccountId,
+ Guid userAccountId,
+ PatronSupportEventKind kind,
+ decimal amount,
+ string currencyCode,
+ string externalSupportId,
+ DateTimeOffset supportedAtUtc,
+ bool isCurrentRecurringSupport,
+ string notes,
+ CancellationToken cancellationToken)
+ {
+ var user = await dbContext.UserAccounts
+ .Include(x => x.Membership)
+ .SingleAsync(x => x.Id == userAccountId, cancellationToken);
+
+ if (user.Membership is null || user.Membership.Status != MembershipStatus.Active)
+ {
+ throw new InvalidOperationException("Patron support can only be recorded for an active member.");
+ }
+
+ if (amount <= 0)
+ {
+ throw new InvalidOperationException("Patron support amount must be greater than zero.");
+ }
+
+ var now = timeProvider.GetUtcNow();
+ var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode)
+ ? "USD"
+ : currencyCode.Trim().ToUpperInvariant();
+
+ if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport)
+ {
+ var existingCurrentEvents = await dbContext.PatronSupportEvents
+ .Where(x => x.UserAccountId == userAccountId &&
+ x.Kind == PatronSupportEventKind.RecurringSupportSnapshot &&
+ x.IsCurrentRecurringSupport)
+ .ToListAsync(cancellationToken);
+
+ foreach (var existing in existingCurrentEvents)
+ {
+ existing.IsCurrentRecurringSupport = false;
+ }
+ }
+
+ var supportEvent = new PatronSupportEvent
+ {
+ UserAccountId = userAccountId,
+ Kind = kind,
+ ExternalSupportId = externalSupportId.Trim(),
+ Amount = amount,
+ CurrencyCode = normalizedCurrency,
+ IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport,
+ SupportedAtUtc = supportedAtUtc.ToUniversalTime(),
+ RecordedAtUtc = now,
+ Notes = notes.Trim()
+ };
+
+ dbContext.PatronSupportEvents.Add(supportEvent);
+ dbContext.PointTransactions.Add(new PointTransaction
+ {
+ UserAccountId = userAccountId,
+ Type = PointTransactionType.PatronSupport,
+ Amount = amount,
+ IsDecaying = kind != PatronSupportEventKind.RecurringSupportSnapshot,
+ CreatedAtUtc = now,
+ Note = $"Recorded {kind} patron support event {supportEvent.ExternalSupportId}."
+ });
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await auditTrailService.RecordAsync(
+ actorUserAccountId,
+ nameof(PatronSupportEvent),
+ supportEvent.Id,
+ "patron-support.recorded",
+ $"Recorded {amount:0.##} {normalizedCurrency} {kind} patron support for {user.DisplayName}.",
+ cancellationToken);
+
+ await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken);
+ return supportEvent;
+ }
+
+ public async Task RefreshPatronTierSnapshotAsync(
+ Guid actorUserAccountId,
+ Guid userAccountId,
+ CancellationToken cancellationToken)
+ {
+ dbContext.ChangeTracker.Clear();
+
+ var user = await dbContext.UserAccounts
+ .Include(x => x.Membership)
+ .Include(x => x.PatronSupportEvents)
+ .SingleAsync(x => x.Id == userAccountId, cancellationToken);
+
+ if (user.Membership is null)
+ {
+ throw new InvalidOperationException("Cannot refresh patron tier for a user without membership.");
+ }
+
+ var summary = CalculatePatronPoints(user.PatronSupportEvents, timeProvider.GetUtcNow());
+ var tier = PatronTierPolicy.ResolveTier(summary.EffectivePoints);
+ var now = timeProvider.GetUtcNow();
+
+ var currentPatronSnapshots = await dbContext.TierSnapshots
+ .Where(x => x.MembershipId == user.Membership.Id &&
+ x.Kind == TierSnapshotKind.Patron &&
+ x.IsCurrent)
+ .ToListAsync(cancellationToken);
+
+ foreach (var snapshot in currentPatronSnapshots)
+ {
+ snapshot.IsCurrent = false;
+ }
+
+ if (tier is not null)
+ {
+ dbContext.TierSnapshots.Add(new TierSnapshot
+ {
+ MembershipId = user.Membership.Id,
+ Kind = TierSnapshotKind.Patron,
+ Label = PatronTierPolicy.SnapshotLabel(tier),
+ Weight = tier.VotingWeight,
+ IsCurrent = true,
+ EffectiveFromUtc = now,
+ CapturedAtUtc = now,
+ Notes = PatronTierPolicy.SnapshotNote(summary.EffectivePoints, now)
+ });
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await auditTrailService.RecordAsync(
+ actorUserAccountId,
+ nameof(TierSnapshot),
+ user.Membership.Id,
+ "patron-tier.derived",
+ tier is null
+ ? $"Cleared patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points."
+ : $"Derived {tier.Label} patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points.",
+ cancellationToken);
+
+ return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m };
+ }
+
+ public static PatronPointSummary CalculatePatronPoints(
+ IEnumerable supportEvents,
+ DateTimeOffset nowUtc)
+ {
+ var events = supportEvents.ToList();
+ var recurringPoints = events
+ .Where(x => x.Kind == PatronSupportEventKind.RecurringSupportSnapshot && x.IsCurrentRecurringSupport)
+ .Sum(x => Math.Max(0m, x.Amount));
+
+ var historicalPoints = events
+ .Where(x => x.Kind != PatronSupportEventKind.RecurringSupportSnapshot || !x.IsCurrentRecurringSupport)
+ .Sum(x => PatronTierPolicy.GetHistoricalDonationPoints(x, nowUtc));
+
+ var effectivePoints = recurringPoints + historicalPoints;
+ var tier = PatronTierPolicy.ResolveTier(effectivePoints);
+
+ return new PatronPointSummary(
+ recurringPoints,
+ historicalPoints,
+ effectivePoints,
+ tier?.Label ?? string.Empty,
+ tier?.VotingWeight ?? 0m);
+ }
+}
+
+public sealed record PatronPointSummary(
+ decimal RecurringPoints,
+ decimal HistoricalPoints,
+ decimal EffectivePoints,
+ string TierLabel,
+ decimal VotingWeight);
diff --git a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml
index eee0c52..80520c7 100644
--- a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml
+++ b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml
@@ -14,8 +14,8 @@
- Post-alpha economics automation is planned separately. The schema now leaves room for point transactions,
- patron support events, weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
+ Patron support events now feed derived tier snapshots for voting weight. Broader economics automation still leaves room for point transactions,
+ weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
@if (Model.Actor.CanManageLedger)
@@ -81,6 +81,72 @@
}
+
+ Patronage
+ Support events
+
+ Patron voting power is derived from support events and tier snapshots. Reddit and Discord can organize the argument;
+ this ledger records the support state that decides weight.
+
+
+ @if (Model.Actor.CanManageLedger)
+ {
+
+ }
+ else
+ {
+
+ Patron support recording is limited to finance reviewers and platform admins.
+
+ }
+
+