using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class CreditStore { private readonly IMongoCollection _accounts; private readonly IMongoCollection _ledger; private readonly TimeProvider _clock; private readonly ServiceMetrics _metrics; public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics) { _accounts = mongo.Database.GetCollection("UserCreditAccounts"); _ledger = mongo.Database.GetCollection("CreditLedgerEntries"); _clock = clock; _metrics = metrics; _accounts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); _ledger.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId) .Descending(value => value.CreatedUtc).Descending(value => value.Id), new CreateIndexOptions { Name = "credit_ledger_user_time" })); } public async Task AwardAsync(string userId, CreditAwardRequest request) { if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0) return new CreditAwardResult { Status = await GetStatusAsync(userId) }; var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var entryId = LedgerId(userId, request.OperationId); var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync(); if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId)) throw new InvalidOperationException("The credit operation ID was already used for another event."); if (entry is null) { var alreadyApplied = await _accounts.Find(value => value.UserId == userId && value.AppliedOperations.Contains(request.OperationId)).AnyAsync(); if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) }; entry = new CreditLedgerEntry { Id = entryId, UserId = userId, OperationId = request.OperationId, RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount, ReasonCode = request.ReasonCode, Description = request.Description, SourceType = request.SourceType, SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap, CreatedUtc = now }; try { await _ledger.InsertOneAsync(entry); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); } } if (entry.State == "applied") { _metrics.CreditDuplicate(request.ReasonCode); return new CreditAwardResult { Status = await GetStatusAsync(userId) }; } var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); if (account.AppliedOperations.Contains(request.OperationId)) { var appliedAmount = AppliedAmount(account, entry); entry.Amount = appliedAmount; await MarkLedgerAppliedAsync(entry, appliedAmount, now); return new CreditAwardResult { Status = await GetStatusAsync(userId) }; } var amount = request.Amount; var appliedNow = false; while (true) { var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)); amount = request.SubjectToRollingCap ? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned)) : request.Amount; var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]); if (request.SubjectToRollingCap && amount > 0) filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount); var update = Builders.Update .AddToSet(value => value.AppliedOperations, request.OperationId) .Set(value => value.UpdatedUtc, now); if (amount > 0) { update = update.Inc(value => value.Credits, amount); if (request.SubjectToRollingCap) update = update.Push(value => value.RecentEarnings, new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now }); } if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1) { appliedNow = true; break; } account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); if (account.AppliedOperations.Contains(request.OperationId)) { amount = AppliedAmount(account, entry); break; } } entry.Amount = amount; await MarkLedgerAppliedAsync(entry, amount, now); if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount); if (appliedNow && request.SubjectToRollingCap && amount < request.Amount) _metrics.CreditCapped(request.ReasonCode); var status = await GetStatusAsync(userId); return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status }; } public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable operationIds) { await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); var update = Builders.Update .Set(value => value.MasteryAwardBaselineApplied, true); foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal)) update = update.AddToSet(value => value.AppliedOperations, operationId); await _accounts.UpdateOneAsync( value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update); } public async Task IsMasteryBaselineAppliedAsync(string userId) { await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied; } public async Task> GetAppliedOperationsAsync(string userId) { await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); return account.AppliedOperations.ToHashSet(StringComparer.Ordinal); } public async Task GetLedgerAsync(string userId, int limit, DateTime? before) { limit = Math.Clamp(limit, 1, 50); var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Eq(value => value.State, "applied") & Builders.Filter.Ne(value => value.Amount, 0); if (before.HasValue) filter &= Builders.Filter.Lt(value => value.CreatedUtc, before.Value); var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc) .ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync(); var hasMore = entries.Count > limit; if (hasMore) entries.RemoveAt(entries.Count - 1); return new CreditLedgerPage { Items = entries.Select(value => new CreditLedgerEntryResponse { Amount = value.Amount, ReasonCode = value.ReasonCode, Description = value.Description, CreatedUtc = value.CreatedUtc }).ToList(), NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null }; } public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default) { var pending = await _ledger.Find(value => value.State == "pending") .SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken); foreach (var entry in pending) { cancellationToken.ThrowIfCancellationRequested(); await AwardAsync(entry.UserId, new CreditAwardRequest( entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description, entry.SourceType, entry.SourceId, entry.SubjectToRollingCap)); } } public async Task GetStatusAsync(string userId) { var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); return ToStatus(account); } public async Task GetAppliedAwardAsync(string userId, string operationId) { var entry = await _ledger.Find(value => value.Id == LedgerId(userId, operationId) && value.State == "applied").FirstOrDefaultAsync(); return entry is null || entry.Amount <= 0 ? null : new CreditAward { Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description }; } public async Task GetBankCycleAsync(string userId) { await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle; } public async Task PurchaseAsync( string userId, RzStoreProduct product, string operationId) { var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync(); var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId); if (prior is not null) { if (prior.Sku != product.Sku) return Response(CreditOperationResult.OperationConflict, existing); await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc); return Response(CreditOperationResult.Succeeded, existing); } var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Gte(value => value.Credits, product.CoinPrice) & Builders.Filter.Not( Builders.Filter.ElemMatch( value => value.Purchases, purchase => purchase.OperationId == operationId)); var update = Builders.Update .Inc(value => value.Credits, -product.CoinPrice) .Push(value => value.Purchases, new CreditPurchaseEvent { OperationId = operationId, Sku = product.Sku, CoinCost = product.CoinPrice, CreatedUtc = now }) .Set(value => value.UpdatedUtc, now); update = product.Grant == RzStoreGrant.ExamToken ? update.Inc(value => value.ExamTokens, 1) : update.Inc(value => value.QBankResetTokens, 1); var account = await _accounts.FindOneAndUpdateAsync( filter, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (account is not null) { await RecordPurchaseLedgerAsync(userId, product, operationId, now); return Response(CreditOperationResult.Succeeded, account); } account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId); if (prior is not null) { if (prior.Sku != product.Sku) return Response(CreditOperationResult.OperationConflict, account); await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc); return Response(CreditOperationResult.Succeeded, account); } return Response(CreditOperationResult.InsufficientCredits, account); } public async Task ApplyQBankResetAsync(string userId, string operationId) { var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Gt(value => value.QBankResetTokens, 0) & Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); var update = Builders.Update .Inc(value => value.QBankResetTokens, -1) .Inc(value => value.BankCycle, 1) .AddToSet(value => value.AppliedOperations, operationId) .Set(value => value.UpdatedUtc, now); var account = await _accounts.FindOneAndUpdateAsync( filter, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (account is not null) return Response(CreditOperationResult.Succeeded, account); account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); if (account.AppliedOperations.Contains(operationId)) return Response(CreditOperationResult.Succeeded, account); return Response(CreditOperationResult.InsufficientTokens, account); } public async Task SpinAsync(string userId, string operationId) { var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync(); var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId); if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin); var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble()); var netCreditDelta = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount - SlotMachineEconomy.SpinCost : -SlotMachineEconomy.SpinCost; var spinEvent = new SpinEvent { OperationId = operationId, RewardKey = reward.Key, CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0, CreatedUtc = now }; var belowSpinCap = new BsonDocumentFilterDefinition( new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray { new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })), SlotMachineEconomy.RollingSpinLimit }))); var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) & Builders.Filter.Not( Builders.Filter.ElemMatch( value => value.Spins, spin => spin.OperationId == operationId)) & belowSpinCap; var update = Builders.Update .Inc(value => value.Credits, netCreditDelta) .Push(value => value.Spins, spinEvent) .Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now }) .Set(value => value.UpdatedUtc, now); update = reward.Grant switch { RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1), RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1), _ => update }; var account = await _accounts.FindOneAndUpdateAsync( filter, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent); account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId); if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin); if (account.Credits < SlotMachineEconomy.SpinCost) return SpinResult(CreditOperationResult.InsufficientCredits, account, null); return SpinResult(CreditOperationResult.RateLimited, account, null); } public async Task TryConsumeExamTokenAsync(string userId, string operationId) { var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Gt(value => value.ExamTokens, 0) & Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); var update = Builders.Update .Inc(value => value.ExamTokens, -1) .AddToSet(value => value.AppliedOperations, operationId) .Set(value => value.UpdatedUtc, now); return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; } public Task RefundExamTokenAsync(string userId, string operationId) => _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.AnyEq(value => value.AppliedOperations, operationId), Builders.Update .Inc(value => value.ExamTokens, 1) .Pull(value => value.AppliedOperations, operationId) .Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime)); private async Task EnsureAndCleanAsync(string userId, DateTime now) { await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update .SetOnInsert(value => value.UserId, userId) .SetOnInsert(value => value.Credits, 0) .SetOnInsert(value => value.ExamTokens, 1) .SetOnInsert(value => value.QBankResetTokens, 0) .SetOnInsert(value => value.BankCycle, 0) .SetOnInsert(value => value.InitialExamGrantApplied, true) .SetOnInsert(value => value.MasteryAwardBaselineApplied, false) .SetOnInsert(value => value.RecentEarnings, []) .SetOnInsert(value => value.Purchases, []) .SetOnInsert(value => value.Spins, []) .SetOnInsert(value => value.RecentSpins, []) .SetOnInsert(value => value.AppliedOperations, []) .SetOnInsert(value => value.UpdatedUtc, now), new UpdateOptions { IsUpsert = true }); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Ne(value => value.InitialExamGrantApplied, true), Builders.Update .Inc(value => value.ExamTokens, 1) .Set(value => value.InitialExamGrantApplied, true)); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), Builders.Update.Set(value => value.RecentEarnings, [])); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.Purchases), false), Builders.Update.Set(value => value.Purchases, [])); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.Spins), false), Builders.Update.Set(value => value.Spins, [])); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false), Builders.Update.Set(value => value.RecentSpins, [])); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false), Builders.Update.Set(value => value.AppliedOperations, [])); var cutoff = now - CreditEconomy.EarningWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update.PullFilter( value => value.RecentEarnings, earning => earning.CreatedUtc <= cutoff)); var spinCutoff = now - SlotMachineEconomy.SpinWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update.PullFilter( value => value.RecentSpins, spin => spin.CreatedUtc <= spinCutoff)); } private static CreditStatusResponse ToStatus(UserCreditAccount account) => new() { Credits = account.Credits, EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)), RemainingEarningCapacity = Math.Max( 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))), ExamTokens = account.ExamTokens, QBankResetTokens = account.QBankResetTokens, BankCycle = account.BankCycle }; private static CreditOperationResponse Response( CreditOperationResult result, UserCreditAccount account) => new() { Result = result, Status = ToStatus(account) }; private static SpinResponse SpinResult( CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new() { Result = result, RewardKey = spinEvent?.RewardKey ?? "", RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "", CoinsWon = spinEvent?.CoinsWon ?? 0, Status = ToStatus(account) }; private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}"; private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) => entry.SubjectToRollingCap ? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0 : entry.RequestedAmount; private static FilterDefinition RollingEarnedAtMost(int maximum) => new BsonDocumentFilterDefinition(new BsonDocument("$expr", new BsonDocument("$lte", new BsonArray { new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument { { "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) }, { "as", "earning" }, { "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) } })), maximum }))); private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) => _ledger.UpdateOneAsync(value => value.Id == entry.Id, Builders.Update.Set(value => value.Amount, amount) .Set(value => value.State, "applied").Set(value => value.AppliedUtc, now)); private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) => _ledger.ReplaceOneAsync( value => value.Id == LedgerId(userId, $"purchase:{operationId}"), new CreditLedgerEntry { Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId, OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice, Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase, Description = $"Purchased {product.Name}", SourceType = "store_product", SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc }, new ReplaceOptions { IsUpsert = true }); private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new() { Award = entry.Amount <= 0 ? null : new CreditAward { Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description }, Status = status }; }