Add freemium access economy and token store

This commit is contained in:
hz
2026-07-27 22:20:21 -05:00
parent 1d5d03a4f6
commit 4bbac27b84
40 changed files with 1807 additions and 37 deletions
+258
View File
@@ -0,0 +1,258 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class AccessStore
{
private readonly IMongoCollection<UserAccessAccount> _accounts;
private readonly TimeProvider _time;
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
{
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
_time = time;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
}
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return ToStatus(account, now, bypass);
}
public async Task<int> GetBankCycleAsync(string userId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
{
if (bypass) return true;
var status = await GetStatusAsync(userId);
return status.Unlimited || status.QuestionsRemaining > 0;
}
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
{
if (bypass) return true;
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
item => item.Id == completionId && item.SubjectId == questionExternalId);
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
return true;
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
{
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
AccessCatalog.FreeQuestionLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
capacity);
var update = Builders<UserAccessAccount>.Update
.AddToSet(value => value.RecentUsage, new AccessEvent
{
Id = completionId,
SubjectId = questionExternalId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
return true;
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
}
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
{
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
AccessCatalog.CreditEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
capacity;
var update = Builders<UserAccessAccount>.Update
.Inc(value => value.Credits, 1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
token.Trim().ToLowerInvariant() switch
{
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
_ => Task.FromResult<AccessStatusResponse?>(null)
};
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Gt(value => value.ExamTokens, 0) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var update = Builders<UserAccessAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task RefundExamTokenAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now));
}
public async Task<int?> ApplyResetAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Gt(value => value.ResetTokens, 0) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var updated = await _accounts.FindOneAndUpdateAsync(
filter,
Builders<UserAccessAccount>.Update
.Inc(value => value.ResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1),
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
return updated?.BankCycle;
}
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var update = Builders<UserAccessAccount>.Update
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
update = product.Grant switch
{
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
_ => update
};
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
{
if (string.IsNullOrWhiteSpace(operationId)) return null;
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
filter &= tokenProperty switch
{
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
_ => FilterDefinition<UserAccessAccount>.Empty
};
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
if (account is null) return null;
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
var update = Builders<UserAccessAccount>.Update
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
update = tokenProperty switch
{
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
_ => update
};
var applied = await _accounts.UpdateOneAsync(
filter & filters.Eq(value => value.Revision, account.Revision), update);
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserAccessAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
var cutoff = now.AddHours(-24);
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserAccessAccount>.Update
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
}
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
{
var unlimited = bypass || account.UnlimitedUntilUtc > now;
var used = account.RecentUsage.Count;
return new AccessStatusResponse
{
QuestionsUsed = used,
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
: null,
Credits = account.Credits,
DayTokens = account.DayTokens,
WeekTokens = account.WeekTokens,
MonthTokens = account.MonthTokens,
ExamTokens = account.ExamTokens,
ResetTokens = account.ResetTokens,
BankCycle = account.BankCycle,
Unlimited = unlimited,
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
Products = AccessCatalog.Products.Values.ToList()
};
}
}