Add Stripe donation flow
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DonationStore
|
||||
{
|
||||
private readonly IMongoCollection<Donation> _donations;
|
||||
|
||||
public DonationStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_donations = mongo.Database.GetCollection<Donation>("Donations");
|
||||
_donations.Indexes.CreateMany([
|
||||
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.StripeCheckoutSessionId),
|
||||
new CreateIndexOptions<Donation>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<Donation>.Filter.Type(value => value.StripeCheckoutSessionId, MongoDB.Bson.BsonType.String)
|
||||
}),
|
||||
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc))
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<Donation> CreateAsync(string userId, long amountCents)
|
||||
{
|
||||
var donation = new Donation { UserId = userId, AmountCents = amountCents };
|
||||
await _donations.InsertOneAsync(donation);
|
||||
return donation;
|
||||
}
|
||||
|
||||
public async Task<Donation?> GetOwnedAsync(string id, string userId) =>
|
||||
await _donations.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task AttachCheckoutSessionAsync(string id, string sessionId) =>
|
||||
await _donations.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == DonationStatus.Pending,
|
||||
Builders<Donation>.Update.Set(value => value.StripeCheckoutSessionId, sessionId).Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task MarkCheckoutCreationFailedAsync(string id, string failureCode) =>
|
||||
await _donations.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == DonationStatus.Pending,
|
||||
Builders<Donation>.Update.Set(value => value.Status, DonationStatus.Failed)
|
||||
.Set(value => value.FailureCode, failureCode).Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<bool> ApplyStripeStatusAsync(
|
||||
string id,
|
||||
string sessionId,
|
||||
long amountCents,
|
||||
string currency,
|
||||
string status,
|
||||
string paymentIntentId,
|
||||
string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return false;
|
||||
var filter = Builders<Donation>.Filter.Eq(value => value.Id, id)
|
||||
& Builders<Donation>.Filter.Eq(value => value.AmountCents, amountCents)
|
||||
& Builders<Donation>.Filter.Eq(value => value.Currency, currency.ToLowerInvariant())
|
||||
& (Builders<Donation>.Filter.Eq(value => value.StripeCheckoutSessionId, sessionId)
|
||||
| Builders<Donation>.Filter.Eq(value => value.StripeCheckoutSessionId, null));
|
||||
|
||||
if (status != DonationStatus.Paid)
|
||||
filter &= Builders<Donation>.Filter.Eq(value => value.Status, DonationStatus.Pending);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<Donation>.Update.Set(value => value.Status, status)
|
||||
.Set(value => value.StripeCheckoutSessionId, sessionId)
|
||||
.Set(value => value.StripePaymentIntentId, paymentIntentId)
|
||||
.Set(value => value.LastStripeEventId, eventId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
if (status == DonationStatus.Paid) update = update.Set(value => value.PaidUtc, now);
|
||||
return (await _donations.UpdateOneAsync(filter, update)).ModifiedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed record StripeCheckoutResult(string SessionId, string CheckoutUrl);
|
||||
|
||||
public interface IStripeDonationGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(string donationId, long amountCents, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeDonationGateway(IConfiguration configuration) : IStripeDonationGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(string donationId, long amountCents, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:SuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:CancelUrl"] ?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var client = new StripeClient(secretKey);
|
||||
var service = new SessionService(client);
|
||||
var metadata = new Dictionary<string, string> { ["donation_id"] = donationId };
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = donationId,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = "usd",
|
||||
UnitAmount = amountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = "Support MedMind",
|
||||
Description = "One-time contribution supporting MedMind development and medical education content."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user