Add patron support derived voting tiers
This commit is contained in:
parent
35dd658185
commit
ba09539561
15 changed files with 695 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
54
src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs
Normal file
54
src/Bifrost.Web/Features/Patronage/PatronTierPolicy.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
186
src/Bifrost.Web/Features/Patronage/PatronageService.cs
Normal file
186
src/Bifrost.Web/Features/Patronage/PatronageService.cs
Normal 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
102
tests/Bifrost.Web.Tests/PatronageServiceTests.cs
Normal file
102
tests/Bifrost.Web.Tests/PatronageServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue