Add patron support derived voting tiers

This commit is contained in:
Meta Vens 2026-06-09 16:28:30 +01:00
commit ba09539561
15 changed files with 695 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -333,9 +333,11 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
modelBuilder.Entity<PatronSupportEvent>(entity =>
{
entity.Property(x => x.Kind).HasConversion<string>();
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)

View file

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[Migration("20260609143000_AddPatronSupportDerivation")]
public partial class AddPatronSupportDerivation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCurrentRecurringSupport",
table: "PatronSupportEvents",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
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" });
}
/// <inheritdoc />
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");
}
}
}

View file

@ -486,6 +486,13 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<bool>("IsCurrentRecurringSupport")
.HasColumnType("boolean");
b.Property<string>("Kind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
@ -503,6 +510,8 @@ namespace Bifrost.Web.Data.Migrations
b.HasIndex("UserAccountId");
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.ToTable("PatronSupportEvents");
});

View file

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

View file

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

View file

@ -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<PatronSupportEvent> 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<PatronPointSummary> 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<PatronSupportEvent> 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);

View file

@ -14,8 +14,8 @@
</p>
<div class="callout">
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.
</div>
@if (Model.Actor.CanManageLedger)
@ -81,6 +81,72 @@
}
</article>
<article class="panel">
<span class="eyebrow">Patronage</span>
<h2>Support events</h2>
<p class="panel-intro">
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.
</p>
@if (Model.Actor.CanManageLedger)
{
<form method="post" asp-page-handler="RecordPatronSupport">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="PatronSupport.UserAccountId"></label>
<select asp-for="PatronSupport.UserAccountId" asp-items="Model.MemberOptions"></select>
<span asp-validation-for="PatronSupport.UserAccountId"></span>
</div>
<div class="field">
<label asp-for="PatronSupport.Kind"></label>
<select asp-for="PatronSupport.Kind" asp-items="Html.GetEnumSelectList<Bifrost.Web.Domain.PatronSupportEventKind>()"></select>
<span asp-validation-for="PatronSupport.Kind"></span>
</div>
<div class="field">
<label asp-for="PatronSupport.Amount"></label>
<input asp-for="PatronSupport.Amount" />
<span asp-validation-for="PatronSupport.Amount"></span>
</div>
<div class="field">
<label asp-for="PatronSupport.CurrencyCode"></label>
<input asp-for="PatronSupport.CurrencyCode" />
<span asp-validation-for="PatronSupport.CurrencyCode"></span>
</div>
<div class="field">
<label asp-for="PatronSupport.ExternalSupportId"></label>
<input asp-for="PatronSupport.ExternalSupportId" />
<span asp-validation-for="PatronSupport.ExternalSupportId"></span>
</div>
<div class="field">
<label asp-for="PatronSupport.SupportedAtUtc"></label>
<input asp-for="PatronSupport.SupportedAtUtc" />
<span asp-validation-for="PatronSupport.SupportedAtUtc"></span>
</div>
</div>
<label class="check-row">
<input asp-for="PatronSupport.IsCurrentRecurringSupport" />
<span>Mark as the current recurring support snapshot</span>
</label>
<div class="field">
<label asp-for="PatronSupport.Notes"></label>
<textarea asp-for="PatronSupport.Notes"></textarea>
<span asp-validation-for="PatronSupport.Notes"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Record patron support</button>
</div>
</form>
}
else
{
<div class="callout warning">
Patron support recording is limited to finance reviewers and platform admins.
</div>
}
</article>
<article class="panel">
<div class="panel-header">
<div>
@ -109,6 +175,73 @@
</article>
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Recent patron support</h2>
<p class="table-meta">@Model.PatronSupportEvents.Count support events in the current view.</p>
</div>
</div>
@if (Model.PatronSupportEvents.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No patron support recorded yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Member</th>
<th>Support</th>
<th>Derived patron state</th>
<th>Receipt</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach (var support in Model.PatronSupportEvents)
{
<tr>
<td>@support.MemberName</td>
<td>
<span class="pill">@support.Kind</span><br />
@support.Amount.ToString("0.##") @support.CurrencyCode
@if (support.IsCurrentRecurringSupport)
{
<div><span class="pill success">current recurring</span></div>
}
</td>
<td>
@support.EffectivePoints.ToString("0.##") effective pts<br />
<span class="muted">
@(string.IsNullOrWhiteSpace(support.TierLabel)
? "No patron tier"
: $"{support.TierLabel} weight {support.VotingWeight:0.##}")
</span>
</td>
<td>
<span class="muted">@support.ExternalSupportId</span><br />
<span class="muted">@support.SupportedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</span>
</td>
<td>
@if (Model.Actor.CanManageLedger)
{
<form method="post" asp-page-handler="RefreshPatronTier">
<input type="hidden" name="userAccountId" value="@support.UserAccountId" />
<button class="button ghost" type="submit">Refresh tier</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>

View file

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@ -13,11 +14,18 @@ public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
PatronageService patronageService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public CreateLedgerEntryInput Input { get; set; } = new();
[BindProperty]
public RecordPatronSupportInput PatronSupport { get; set; } = new()
{
SupportedAtUtc = DateTimeOffset.UtcNow
};
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
@ -28,6 +36,8 @@ public sealed class IndexModel(
public IReadOnlyList<LedgerEntry> LedgerEntries { get; private set; } = [];
public IReadOnlyList<PatronSupportListItem> PatronSupportEvents { get; private set; } = [];
public IReadOnlyList<PayoutPreviewItem> PayoutPreview { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
@ -43,7 +53,8 @@ public sealed class IndexModel(
return Forbid();
}
if (!ModelState.IsValid)
ModelState.Clear();
if (!TryValidateModel(Input, nameof(Input)))
{
await LoadAsync(cancellationToken);
return Page();
@ -78,6 +89,48 @@ public sealed class IndexModel(
return RedirectToPage();
}
public async Task<IActionResult> OnPostRecordPatronSupportAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageLedger || Actor.UserAccount is null)
{
return Forbid();
}
ModelState.Clear();
if (!TryValidateModel(PatronSupport, nameof(PatronSupport)))
{
await LoadAsync(cancellationToken);
return Page();
}
await patronageService.RecordSupportEventAsync(
Actor.UserAccount.Id,
PatronSupport.UserAccountId,
PatronSupport.Kind,
PatronSupport.Amount,
PatronSupport.CurrencyCode,
PatronSupport.ExternalSupportId ?? string.Empty,
PatronSupport.SupportedAtUtc,
PatronSupport.IsCurrentRecurringSupport,
PatronSupport.Notes ?? string.Empty,
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostRefreshPatronTierAsync(Guid userAccountId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageLedger || Actor.UserAccount is null)
{
return Forbid();
}
await patronageService.RefreshPatronTierSnapshotAsync(Actor.UserAccount.Id, userAccountId, cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
@ -111,6 +164,36 @@ public sealed class IndexModel(
.Take(20)
.ToListAsync(cancellationToken);
var allSupportEvents = await dbContext.PatronSupportEvents
.AsNoTracking()
.Include(x => x.UserAccount)
.OrderByDescending(x => x.RecordedAtUtc)
.ToListAsync(cancellationToken);
PatronSupportEvents = allSupportEvents
.Take(20)
.Select(x =>
{
var summary = PatronageService.CalculatePatronPoints(
allSupportEvents.Where(item => item.UserAccountId == x.UserAccountId),
timeProvider.GetUtcNow());
return new PatronSupportListItem(
x.UserAccountId,
x.UserAccount.DisplayName,
x.Kind,
x.Amount,
x.CurrencyCode,
x.IsCurrentRecurringSupport,
x.SupportedAtUtc,
x.RecordedAtUtc,
x.ExternalSupportId,
summary.EffectivePoints,
summary.TierLabel,
summary.VotingWeight);
})
.ToList();
PayoutPreview = await dbContext.LedgerEntries
.AsNoTracking()
.Where(x => x.Status == LedgerEntryStatus.Approved)
@ -152,5 +235,49 @@ public sealed class IndexModel(
public string Note { get; set; } = string.Empty;
}
public sealed class RecordPatronSupportInput
{
[Required]
[Display(Name = "Member")]
public Guid UserAccountId { get; set; }
[Display(Name = "Support kind")]
public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation;
[Range(0.01, 100000000)]
public decimal Amount { get; set; }
[Display(Name = "Currency")]
[StringLength(12)]
public string CurrencyCode { get; set; } = "USD";
[Display(Name = "External support id")]
[StringLength(240)]
public string? ExternalSupportId { get; set; }
[Display(Name = "Current recurring support")]
public bool IsCurrentRecurringSupport { get; set; }
[Display(Name = "Supported at (UTC)")]
public DateTimeOffset SupportedAtUtc { get; set; }
[StringLength(3000)]
public string? Notes { get; set; }
}
public sealed record PayoutPreviewItem(string MemberName, decimal Points, decimal NominalAmount);
public sealed record PatronSupportListItem(
Guid UserAccountId,
string MemberName,
PatronSupportEventKind Kind,
decimal Amount,
string CurrencyCode,
bool IsCurrentRecurringSupport,
DateTimeOffset SupportedAtUtc,
DateTimeOffset RecordedAtUtc,
string ExternalSupportId,
decimal EffectivePoints,
string TierLabel,
decimal VotingWeight);
}

View file

@ -202,10 +202,10 @@
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.8rem;">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="hidden" name="kind" value="@TierSnapshotKind.Patron" />
<input name="label" value="@user.PatronTierLabel" placeholder="Patron tier label" />
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Weight" />
<input name="notes" placeholder="Notes" />
<button class="button ghost" type="submit">Save patron tier</button>
<input name="label" value="@user.PatronTierLabel" placeholder="Patron override label" />
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Override weight" />
<input name="notes" placeholder="Override notes" />
<button class="button ghost" type="submit">Override patron tier</button>
</form>
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.6rem;">

View file

@ -6,6 +6,7 @@ using Bifrost.Web.Data;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@ -68,6 +69,7 @@ builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();

View file

@ -0,0 +1,102 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Tests;
public sealed class PatronageServiceTests
{
[Fact]
public void Historical_patron_support_halves_after_first_month_and_decays_weekly()
{
var now = new DateTimeOffset(2026, 6, 9, 12, 0, 0, TimeSpan.Zero);
var supportEvent = new PatronSupportEvent
{
Kind = PatronSupportEventKind.OneTimeDonation,
Amount = 100m,
SupportedAtUtc = now.AddDays(-44)
};
var points = PatronTierPolicy.GetHistoricalDonationPoints(supportEvent, now);
Assert.Equal(49m, points);
}
[Fact]
public async Task Recording_patron_support_writes_audit_point_transaction_and_derived_tier()
{
await using var dbContext = CreateDbContext();
var now = DateTimeOffset.UtcNow;
var actor = new UserAccount
{
DisplayName = "Admin",
GitHubLogin = "admin",
NormalizedGitHubLogin = "admin",
CreatedAtUtc = now,
LastSeenAtUtc = now
};
actor.Membership = new Membership
{
UserAccountId = actor.Id,
Status = MembershipStatus.Active,
CreatedAtUtc = now
};
var patron = new UserAccount
{
DisplayName = "Patron",
GitHubLogin = "patron",
NormalizedGitHubLogin = "patron",
CreatedAtUtc = now,
LastSeenAtUtc = now
};
patron.Membership = new Membership
{
UserAccountId = patron.Id,
Status = MembershipStatus.Active,
CreatedAtUtc = now
};
dbContext.UserAccounts.AddRange(actor, patron);
await dbContext.SaveChangesAsync();
var auditTrail = new AuditTrailService(dbContext, TimeProvider.System);
var patronage = new PatronageService(dbContext, auditTrail, TimeProvider.System);
await patronage.RecordSupportEventAsync(
actor.Id,
patron.Id,
PatronSupportEventKind.RecurringSupportSnapshot,
125m,
"usd",
"patreon-member-1",
now,
isCurrentRecurringSupport: true,
"Imported from Patreon current support.",
CancellationToken.None);
var supportEvent = await dbContext.PatronSupportEvents.SingleAsync();
var pointTransaction = await dbContext.PointTransactions.SingleAsync();
var tierSnapshot = await dbContext.TierSnapshots.SingleAsync(x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent);
Assert.Equal(PatronSupportEventKind.RecurringSupportSnapshot, supportEvent.Kind);
Assert.True(supportEvent.IsCurrentRecurringSupport);
Assert.Equal("USD", supportEvent.CurrencyCode);
Assert.Equal(PointTransactionType.PatronSupport, pointTransaction.Type);
Assert.False(pointTransaction.IsDecaying);
Assert.Equal("Patron Silver", tierSnapshot.Label);
Assert.Equal(2m, tierSnapshot.Weight);
Assert.Contains("125", tierSnapshot.Notes);
Assert.Contains(dbContext.AuditEvents, x => x.Action == "patron-support.recorded");
Assert.Contains(dbContext.AuditEvents, x => x.Action == "patron-tier.derived");
}
private static BifrostDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<BifrostDbContext>()
.UseInMemoryDatabase($"patronage-{Guid.NewGuid():N}")
.Options;
return new BifrostDbContext(options);
}
}