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`
|
- 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
|
- GitHub OAuth sign-in plus invite/approval-based membership gating
|
||||||
- explicit member roles and admin-managed patron or contributor tier snapshots
|
- 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
|
- 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
|
- 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
|
- 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 sign-in only
|
||||||
- GitHub-backed and internal work items
|
- GitHub-backed and internal work items
|
||||||
- app-native motions and voting
|
- 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
|
- no real payout execution in v1
|
||||||
|
|
||||||
## Hosting Target
|
## Hosting Target
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
- `Motion`: `Management` or `Project`
|
- `Motion`: `Management` or `Project`
|
||||||
- `Vote`: per-member vote tied to effective voting weight
|
- `Vote`: per-member vote tied to effective voting weight
|
||||||
- `LedgerEntry`: immutable patron/contributor/accounting record
|
- `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
|
- `PayoutProposalBatch`: admin-reviewed payout recommendation batch only
|
||||||
- `AuditEvent`: append-only activity and override log
|
- `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.
|
- 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.
|
- Contributor credit/reward is assigned only after maintainer acceptance of the completion artifact.
|
||||||
- Voting weight derives from patron tier plus contributor tier.
|
- 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.
|
- 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.
|
- Compensation is modeled internally, not executed by the platform.
|
||||||
- Human approval remains in the loop for governance-sensitive and payout-sensitive steps.
|
- 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
|
- invite and approval gate with explicit member roles
|
||||||
- member profile editing plus patron or contributor tier snapshots
|
- member profile editing plus patron or contributor tier snapshots
|
||||||
- project, work item, motion, member, and ledger UI
|
- 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
|
- work logs, work reviews, and completion flow
|
||||||
- GitHub App webhook ingestion for issue, pull request, and review sync
|
- GitHub App webhook ingestion for issue, pull request, and review sync
|
||||||
- health and readiness endpoints plus request logging
|
- 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
|
- backup and restore rehearsal for PostgreSQL and app secrets
|
||||||
- fuller workflow polish around blockers, archival, and review ergonomics
|
- fuller workflow polish around blockers, archival, and review ergonomics
|
||||||
- production-safe payout proposal batching and finance review workflow
|
- 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
|
## 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
|
||||||
|
|
||||||
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:
|
Implementation requirements:
|
||||||
|
|
||||||
- store recurring support state and historical donation events separately
|
- 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
|
- 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
|
- 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
|
- 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
|
### 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.
|
- Heimdall links external provider accounts to a GameCult account.
|
||||||
- Bifrost records patron support events and current recurring support state.
|
- 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.
|
- 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.
|
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 =>
|
modelBuilder.Entity<PatronSupportEvent>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property(x => x.Kind).HasConversion<string>();
|
||||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||||
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
|
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
|
||||||
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
|
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
|
||||||
|
entity.HasIndex(x => new { x.UserAccountId, x.Kind, x.IsCurrentRecurringSupport });
|
||||||
entity.HasOne(x => x.UserAccount)
|
entity.HasOne(x => x.UserAccount)
|
||||||
.WithMany(x => x.PatronSupportEvents)
|
.WithMany(x => x.PatronSupportEvents)
|
||||||
.HasForeignKey(x => x.UserAccountId)
|
.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)
|
.HasMaxLength(240)
|
||||||
.HasColumnType("character varying(240)");
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCurrentRecurringSupport")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
@ -503,6 +510,8 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
|
|
||||||
b.HasIndex("UserAccountId");
|
b.HasIndex("UserAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
|
||||||
|
|
||||||
b.ToTable("PatronSupportEvents");
|
b.ToTable("PatronSupportEvents");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,12 @@ public enum PointTransactionType
|
||||||
Decay
|
Decay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum PatronSupportEventKind
|
||||||
|
{
|
||||||
|
OneTimeDonation,
|
||||||
|
RecurringSupportSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class UserAccount
|
public sealed class UserAccount
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
@ -711,10 +717,14 @@ public sealed class PatronSupportEvent
|
||||||
|
|
||||||
public string ExternalSupportId { get; set; } = string.Empty;
|
public string ExternalSupportId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation;
|
||||||
|
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
public string CurrencyCode { get; set; } = "USD";
|
public string CurrencyCode { get; set; } = "USD";
|
||||||
|
|
||||||
|
public bool IsCurrentRecurringSupport { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset SupportedAtUtc { get; set; }
|
public DateTimeOffset SupportedAtUtc { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset RecordedAtUtc { 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>
|
</p>
|
||||||
|
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
Post-alpha economics automation is planned separately. The schema now leaves room for point transactions,
|
Patron support events now feed derived tier snapshots for voting weight. Broader economics automation still leaves room for point transactions,
|
||||||
patron support events, weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
|
weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.Actor.CanManageLedger)
|
@if (Model.Actor.CanManageLedger)
|
||||||
|
|
@ -81,6 +81,72 @@
|
||||||
}
|
}
|
||||||
</article>
|
</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">
|
<article class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -109,6 +175,73 @@
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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;">
|
<section class="table-panel" style="margin-top: 1.5rem;">
|
||||||
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Bifrost.Web.Data;
|
using Bifrost.Web.Data;
|
||||||
using Bifrost.Web.Domain;
|
using Bifrost.Web.Domain;
|
||||||
|
using Bifrost.Web.Features.Patronage;
|
||||||
using Bifrost.Web.Features.Shared;
|
using Bifrost.Web.Features.Shared;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
@ -13,11 +14,18 @@ public sealed class IndexModel(
|
||||||
BifrostDbContext dbContext,
|
BifrostDbContext dbContext,
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
ICurrentBifrostActorAccessor actorAccessor,
|
||||||
AuditTrailService auditTrailService,
|
AuditTrailService auditTrailService,
|
||||||
|
PatronageService patronageService,
|
||||||
TimeProvider timeProvider) : PageModel
|
TimeProvider timeProvider) : PageModel
|
||||||
{
|
{
|
||||||
[BindProperty]
|
[BindProperty]
|
||||||
public CreateLedgerEntryInput Input { get; set; } = new();
|
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 CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||||
|
|
||||||
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
|
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
|
||||||
|
|
@ -28,6 +36,8 @@ public sealed class IndexModel(
|
||||||
|
|
||||||
public IReadOnlyList<LedgerEntry> LedgerEntries { get; private set; } = [];
|
public IReadOnlyList<LedgerEntry> LedgerEntries { get; private set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<PatronSupportListItem> PatronSupportEvents { get; private set; } = [];
|
||||||
|
|
||||||
public IReadOnlyList<PayoutPreviewItem> PayoutPreview { get; private set; } = [];
|
public IReadOnlyList<PayoutPreviewItem> PayoutPreview { get; private set; } = [];
|
||||||
|
|
||||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
|
@ -43,7 +53,8 @@ public sealed class IndexModel(
|
||||||
return Forbid();
|
return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
ModelState.Clear();
|
||||||
|
if (!TryValidateModel(Input, nameof(Input)))
|
||||||
{
|
{
|
||||||
await LoadAsync(cancellationToken);
|
await LoadAsync(cancellationToken);
|
||||||
return Page();
|
return Page();
|
||||||
|
|
@ -78,6 +89,48 @@ public sealed class IndexModel(
|
||||||
return RedirectToPage();
|
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)
|
private async Task LoadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||||
|
|
@ -111,6 +164,36 @@ public sealed class IndexModel(
|
||||||
.Take(20)
|
.Take(20)
|
||||||
.ToListAsync(cancellationToken);
|
.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
|
PayoutPreview = await dbContext.LedgerEntries
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.Status == LedgerEntryStatus.Approved)
|
.Where(x => x.Status == LedgerEntryStatus.Approved)
|
||||||
|
|
@ -152,5 +235,49 @@ public sealed class IndexModel(
|
||||||
public string Note { get; set; } = string.Empty;
|
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 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;">
|
<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="userAccountId" value="@user.UserAccountId" />
|
||||||
<input type="hidden" name="kind" value="@TierSnapshotKind.Patron" />
|
<input type="hidden" name="kind" value="@TierSnapshotKind.Patron" />
|
||||||
<input name="label" value="@user.PatronTierLabel" placeholder="Patron tier label" />
|
<input name="label" value="@user.PatronTierLabel" placeholder="Patron override label" />
|
||||||
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Weight" />
|
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Override weight" />
|
||||||
<input name="notes" placeholder="Notes" />
|
<input name="notes" placeholder="Override notes" />
|
||||||
<button class="button ghost" type="submit">Save patron tier</button>
|
<button class="button ghost" type="submit">Override patron tier</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.6rem;">
|
<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.GitHub;
|
||||||
using Bifrost.Web.Features.Heimdall;
|
using Bifrost.Web.Features.Heimdall;
|
||||||
using Bifrost.Web.Features.Membership;
|
using Bifrost.Web.Features.Membership;
|
||||||
|
using Bifrost.Web.Features.Patronage;
|
||||||
using Bifrost.Web.Features.Shared;
|
using Bifrost.Web.Features.Shared;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
|
@ -68,6 +69,7 @@ builder.Services.AddScoped<AuditTrailService>();
|
||||||
builder.Services.AddScoped<DashboardSnapshotService>();
|
builder.Services.AddScoped<DashboardSnapshotService>();
|
||||||
builder.Services.AddScoped<MembershipSynchronizationService>();
|
builder.Services.AddScoped<MembershipSynchronizationService>();
|
||||||
builder.Services.AddScoped<GitHubWebhookService>();
|
builder.Services.AddScoped<GitHubWebhookService>();
|
||||||
|
builder.Services.AddScoped<PatronageService>();
|
||||||
builder.Services.AddScoped<ReadinessService>();
|
builder.Services.AddScoped<ReadinessService>();
|
||||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
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