Files
step3/api/QuestionsApi/Services/CreditStore.cs
T
2026-08-01 04:14:39 -05:00

493 lines
26 KiB
C#

using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
}
public async Task<CreditAwardResult> 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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
if (request.SubjectToRollingCap && amount > 0)
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
var update = Builders<UserCreditAccount>.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<string> operationIds)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var update = Builders<UserCreditAccount>.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<bool> IsMasteryBaselineAppliedAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
}
public async Task<HashSet<string>> 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<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.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<CreditStatusResponse> 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<CreditAward?> 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<int> GetBankCycleAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<CreditOperationResponse> 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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Purchases, purchase => purchase.OperationId == operationId));
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
{
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<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
{
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<SpinResponse> 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<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
SlotMachineEconomy.RollingSpinLimit
})));
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Spins, spin => spin.OperationId == operationId)) &
belowSpinCap;
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
{
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<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
Builders<UserCreditAccount>.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<UserCreditAccount>.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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Set(value => value.InitialExamGrantApplied, true));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.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<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
new BsonDocumentFilterDefinition<UserCreditAccount>(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<CreditLedgerEntry>.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
};
}