feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published promise that no viewer, member or donor information is retained after the session ends, and it would have meant owning a ledger that YouTube already keeps. So the report is DERIVED on demand instead: - `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent. - `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals that are never summed together** (a $5 and a €5 are not $10). Nothing is written down. - The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle, persisted, **off by default** — a creator who never monetized is never shown a $0.00. Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated for weeks, not days). Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds a MainViewModel directly and was flaking against parallel tests that swap the static layout path. FAST gate: 557 passed, 0 failed, 0 warnings.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One Super Chat or Super Sticker event, as YouTube reports it.</summary>
|
||||
public sealed record SuperChatEvent(
|
||||
string Id,
|
||||
ChatEventKind Kind,
|
||||
long AmountMicros,
|
||||
string Currency,
|
||||
string? DisplayAmount,
|
||||
DateTime PublishedAt);
|
||||
|
||||
/// <summary>
|
||||
/// TASK 3·20: what this session's rewards added up to — <b>in memory only</b>.
|
||||
/// <para>Derived on demand from <c>superChatEvents.list</c> rather than persisted: YouTube is the system
|
||||
/// of record for money, and keeping a local ledger would contradict the published promise that no
|
||||
/// viewer, member or donor information is retained after the session ends.</para>
|
||||
/// <para>Totals are kept <b>per currency</b> and never summed across currencies — a $5 and a €5 are not
|
||||
/// $10, and a single blended number would be a lie.</para>
|
||||
/// </summary>
|
||||
public sealed record SessionRewardSummary(
|
||||
int SuperChatCount,
|
||||
int SuperStickerCount,
|
||||
IReadOnlyDictionary<string, long> MicrosByCurrency,
|
||||
DateTime? FirstEventAt,
|
||||
DateTime? LastEventAt)
|
||||
{
|
||||
public static readonly SessionRewardSummary Empty = new(
|
||||
0, 0, new Dictionary<string, long>(), null, null);
|
||||
|
||||
public int TotalEvents => SuperChatCount + SuperStickerCount;
|
||||
|
||||
public bool HasEvents => TotalEvents > 0;
|
||||
|
||||
/// <summary>Filters to a session window and rolls the events up. Events outside
|
||||
/// [<paramref name="sinceUtc"/>, <paramref name="untilUtc"/>] are ignored, which is what makes a
|
||||
/// report "this session" rather than "this channel".</summary>
|
||||
public static SessionRewardSummary From(
|
||||
IEnumerable<SuperChatEvent> events, DateTime sinceUtc, DateTime? untilUtc = null)
|
||||
{
|
||||
var until = untilUtc ?? DateTime.MaxValue;
|
||||
var inWindow = events
|
||||
.Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
|
||||
.OrderBy(e => e.PublishedAt)
|
||||
.ToList();
|
||||
|
||||
if (inWindow.Count == 0) return Empty;
|
||||
|
||||
var byCurrency = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var e in inWindow)
|
||||
{
|
||||
var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
|
||||
byCurrency.TryGetValue(currency, out var total);
|
||||
byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
|
||||
}
|
||||
|
||||
return new SessionRewardSummary(
|
||||
inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
|
||||
inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
|
||||
byCurrency,
|
||||
inWindow[0].PublishedAt,
|
||||
inWindow[^1].PublishedAt);
|
||||
}
|
||||
|
||||
/// <summary>Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
|
||||
/// show — a creator who never monetized must never be shown a zero.</summary>
|
||||
public IReadOnlyList<string> MoneyLines()
|
||||
=> MicrosByCurrency
|
||||
.Where(pair => pair.Value > 0)
|
||||
.OrderByDescending(pair => pair.Value)
|
||||
.Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
|
||||
.ToList();
|
||||
|
||||
private static string Format(string currency, long micros)
|
||||
{
|
||||
var amount = micros / 1_000_000m;
|
||||
var symbol = currency switch
|
||||
{
|
||||
"USD" => "$",
|
||||
"EUR" => "€",
|
||||
"GBP" => "£",
|
||||
"CAD" => "CA$",
|
||||
"AUD" => "A$",
|
||||
_ => string.Empty,
|
||||
};
|
||||
return symbol.Length > 0
|
||||
? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user