using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class CreditStore { private readonly IMongoCollection _accounts; private readonly TimeProvider _clock; public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock) { _accounts = mongo.Database.GetCollection("UserCreditAccounts"); _clock = clock; _accounts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); } public async Task AwardForFirstAnswerAsync(string userId, string attemptId) { if (string.IsNullOrWhiteSpace(attemptId)) return false; var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var belowCap = new BsonDocumentFilterDefinition( new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray { new BsonDocument("$size", "$RecentEarnings"), CreditEconomy.RollingEarningLimit }))); var filter = filters.Eq(value => value.UserId, userId) & filters.Not(filters.ElemMatch(value => value.RecentEarnings, earning => earning.OperationId == attemptId)) & belowCap; var update = Builders.Update .Inc(value => value.Credits, 1) .Push(value => value.RecentEarnings, new CreditEvent { OperationId = attemptId, CreatedUtc = now }) .Set(value => value.UpdatedUtc, now); return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; } 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 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) return Response(prior.Sku == product.Sku ? CreditOperationResult.Succeeded : CreditOperationResult.OperationConflict, 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) 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) return Response(prior.Sku == product.Sku ? CreditOperationResult.Succeeded : CreditOperationResult.OperationConflict, 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.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.Count, RemainingEarningCapacity = Math.Max( 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count), 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) }; }