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. +
+ } +
+
@@ -109,6 +175,73 @@
+
+
+
+

Recent patron support

+

@Model.PatronSupportEvents.Count support events in the current view.

+
+
+ + @if (Model.PatronSupportEvents.Count == 0) + { +
No patron support recorded yet.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var support in Model.PatronSupportEvents) + { + + + + + + + + } + +
MemberSupportDerived patron stateReceiptAction
@support.MemberName + @support.Kind
+ @support.Amount.ToString("0.##") @support.CurrencyCode + @if (support.IsCurrentRecurringSupport) + { +
current recurring
+ } +
+ @support.EffectivePoints.ToString("0.##") effective pts
+ + @(string.IsNullOrWhiteSpace(support.TierLabel) + ? "No patron tier" + : $"{support.TierLabel} weight {support.VotingWeight:0.##}") + +
+ @support.ExternalSupportId
+ @support.SupportedAtUtc.ToString("dd MMM yyyy HH:mm") UTC +
+ @if (Model.Actor.CanManageLedger) + { +
+ + +
+ } +
+
+ } +
+
diff --git a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs index 54f1d94..a121a16 100644 --- a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs @@ -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 MemberOptions { get; private set; } = []; @@ -28,6 +36,8 @@ public sealed class IndexModel( public IReadOnlyList LedgerEntries { get; private set; } = []; + public IReadOnlyList PatronSupportEvents { get; private set; } = []; + public IReadOnlyList 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 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 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); } diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml index 773577d..0d9d546 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml @@ -202,10 +202,10 @@
- - - - + + + +
diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 2901011..9218471 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/PatronageServiceTests.cs b/tests/Bifrost.Web.Tests/PatronageServiceTests.cs new file mode 100644 index 0000000..1e1bfb3 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PatronageServiceTests.cs @@ -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() + .UseInMemoryDatabase($"patronage-{Guid.NewGuid():N}") + .Options; + + return new BifrostDbContext(options); + } +}