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
@@ -0,0 +1,96 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class AccessPurchaseStore
{
private readonly IMongoCollection<AccessPurchase> _purchases;
public AccessPurchaseStore(MongoDatabaseProvider mongo)
{
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
_purchases.Indexes.CreateMany([
new CreateIndexModel<AccessPurchase>(
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
new CreateIndexModel<AccessPurchase>(
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
new CreateIndexOptions<AccessPurchase>
{
Unique = true,
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
})
]);
}
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
{
var purchase = new AccessPurchase
{
UserId = userId,
Sku = product.Sku,
AmountCents = product.AmountCents,
Currency = product.Currency
};
await _purchases.InsertOneAsync(purchase);
return purchase;
}
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
public async Task<AccessPurchase?> GetAsync(string id) =>
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
public Task AttachSessionAsync(string id, string sessionId) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
Builders<AccessPurchase>.Update
.Set(value => value.ProviderSessionId, sessionId)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public Task MarkCheckoutFailedAsync(string id) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
Builders<AccessPurchase>.Update
.Set(value => value.Status, AccessPurchaseStatus.Failed)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
string id, string sessionId, long amountCents, string currency,
string status, string paymentId, string eventId)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
var filters = Builders<AccessPurchase>.Filter;
var filter = filters.Eq(value => value.Id, id) &
filters.Eq(value => value.AmountCents, amountCents) &
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
(filters.Eq(value => value.ProviderSessionId, sessionId) |
filters.Eq(value => value.ProviderSessionId, "")) &
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
var update = Builders<AccessPurchase>.Update
.Set(value => value.Status, status)
.Set(value => value.ProviderSessionId, sessionId)
.Set(value => value.ProviderPaymentId, paymentId)
.AddToSet(value => value.ProviderEventIds, eventId)
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
return await _purchases.FindOneAndUpdateAsync(filter, update,
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
}
public Task MarkGrantAppliedAsync(string id) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
Builders<AccessPurchase>.Update
.Set(value => value.GrantApplied, true)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
{
PurchaseId = purchase.Id ?? "",
Sku = purchase.Sku,
AmountCents = purchase.AmountCents,
Currency = purchase.Currency,
Status = purchase.Status
};
}
+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()
};
}
}
+7 -2
View File
@@ -20,6 +20,7 @@ public sealed class DashboardStore
private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly AccessStore _accessStore;
private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems =
@@ -48,7 +49,8 @@ public sealed class DashboardStore
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore,
CcsRatingStore ccsRatingStore)
CcsRatingStore ccsRatingStore,
AccessStore accessStore)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
@@ -65,6 +67,7 @@ public sealed class DashboardStore
_flashcardSchedules = flashcardSchedules;
_ratingStore = ratingStore;
_ccsRatingStore = ccsRatingStore;
_accessStore = accessStore;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -72,7 +75,9 @@ public sealed class DashboardStore
var today = nowUtc.Date;
var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
var bankCycle = await bankCycleTask;
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
{
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
try
{
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
}
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
{
}
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId);
.Ascending(a => a.UserId)
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex,
new CreateIndexOptions { Unique = true }));
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
bool isCorrect,
bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0)
int durationSeconds = 0,
int bankCycle = 0,
string completionId = "")
{
var attempt = new QuestionAttempt
{
QuestionExternalId = questionExternalId,
UserId = userId,
BankCycle = bankCycle,
CompletionId = completionId,
ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -63,7 +75,7 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
}
@@ -75,10 +87,13 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync();
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
_col.Distinct(
attempt => attempt.QuestionExternalId,
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
(bankCycle is null
? FilterDefinition<QuestionAttempt>.Empty
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
.ToListAsync();
public IMongoCollection<QuestionAttempt> Collection => _col;
+12
View File
@@ -41,6 +41,18 @@ public class RatingStore
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
_users.UpdateOneAsync(
rating => rating.UserId == userId,
Builders<UserRating>.Update
.Set(rating => rating.OverallElo, BaseElo)
.Set(rating => rating.RatedQuestionCount, 0)
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
.Set(rating => rating.HistoryBaselineElo, null)
.Set(rating => rating.HistoryStartedUtc, null)
.Set(rating => rating.UpdatedUtc, nowUtc),
new UpdateOptions { IsUpsert = false });
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
{
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
@@ -0,0 +1,63 @@
using QuestionsApi.Models;
using Stripe;
using Stripe.Checkout;
namespace QuestionsApi.Services;
public interface IStripeAccessGateway
{
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
}
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
{
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
{
var secretKey = configuration["Stripe:SecretKey"];
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
var successUrl = configuration["Stripe:AccessSuccessUrl"]
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
?? "https://doctor.ranaze.com/donation-canceled.html";
var metadata = new Dictionary<string, string>
{
["access_purchase_id"] = purchase.Id ?? "",
["access_sku"] = product.Sku,
["user_id"] = purchase.UserId
};
var service = new SessionService(new StripeClient(secretKey));
var session = await service.CreateAsync(new SessionCreateOptions
{
Mode = "payment",
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = purchase.Id,
CustomerCreation = "always",
BillingAddressCollection = "auto",
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
Metadata = metadata,
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
LineItems =
[
new SessionLineItemOptions
{
Quantity = 1,
PriceData = new SessionLineItemPriceDataOptions
{
Currency = product.Currency,
UnitAmount = product.AmountCents,
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = product.Name,
Description = "A manually activated RanaZe access entitlement."
}
}
}
]
}, null, cancellationToken);
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
throw new InvalidOperationException("Stripe did not return a checkout URL.");
return new StripeCheckoutResult(session.Id, session.Url);
}
}
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserAccessAccounts", "AccessPurchases"
];
public UserProfileStore(MongoDatabaseProvider mongo)