Add Stripe donation flow
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class DonationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(499)]
|
||||
[InlineData(1_000_001)]
|
||||
public void RejectsAmountsOutsideConfiguredRange(long amountCents) =>
|
||||
Assert.NotNull(DonationValidator.ValidateAmount(amountCents));
|
||||
|
||||
[Theory]
|
||||
[InlineData(500)]
|
||||
[InlineData(1_000)]
|
||||
[InlineData(1_000_000)]
|
||||
public void AcceptsAmountsInsideConfiguredRange(long amountCents) =>
|
||||
Assert.Null(DonationValidator.ValidateAmount(amountCents));
|
||||
|
||||
[Fact]
|
||||
public void PublicStatusDoesNotExposeStripeOrUserIdentifiers()
|
||||
{
|
||||
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.UserId)));
|
||||
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.StripeCheckoutSessionId)));
|
||||
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.StripePaymentIntentId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController, Route("api/donations")]
|
||||
public sealed class DonationsController(
|
||||
DonationStore donations,
|
||||
IStripeDonationGateway stripe,
|
||||
IConfiguration configuration,
|
||||
ILogger<DonationsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("checkout"), Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> CreateCheckout(CreateDonationCheckoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (DonationValidator.ValidateAmount(request.AmountCents) is { } error) return BadRequest(error);
|
||||
var donation = await donations.CreateAsync(UserId(), request.AmountCents);
|
||||
try
|
||||
{
|
||||
var checkout = await stripe.CreateCheckoutAsync(donation.Id!, donation.AmountCents, cancellationToken);
|
||||
await donations.AttachCheckoutSessionAsync(donation.Id!, checkout.SessionId);
|
||||
return StatusCode(StatusCodes.Status201Created, new DonationCheckoutResponse
|
||||
{
|
||||
DonationId = donation.Id!, CheckoutUrl = checkout.CheckoutUrl, AmountCents = donation.AmountCents
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await donations.MarkCheckoutCreationFailedAsync(donation.Id!, exception is StripeException ? "stripe_error" : "configuration_error");
|
||||
logger.LogError(exception, "Unable to create Stripe Checkout Session for donation {DonationId}", donation.Id);
|
||||
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Donation checkout unavailable",
|
||||
detail: "The secure checkout could not be started. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}"), Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetStatus(string id) =>
|
||||
await donations.GetOwnedAsync(id.Trim(), UserId()) is { } donation
|
||||
? Ok(DonationStatusResponse.FromModel(donation))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook"), AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookSecret = configuration["Stripe:WebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(webhookSecret)) return Problem(statusCode: 503, title: "Stripe webhook is not configured");
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
Event stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Rejected Stripe webhook with an invalid signature");
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
|
||||
if (stripeEvent.Data.Object is not Session session) return Ok();
|
||||
var status = stripeEvent.Type switch
|
||||
{
|
||||
"checkout.session.completed" when session.PaymentStatus == "paid" => DonationStatus.Paid,
|
||||
"checkout.session.async_payment_succeeded" => DonationStatus.Paid,
|
||||
"checkout.session.async_payment_failed" => DonationStatus.Failed,
|
||||
"checkout.session.expired" => DonationStatus.Expired,
|
||||
_ => ""
|
||||
};
|
||||
if (status.Length == 0) return Ok();
|
||||
var donationId = session.ClientReferenceId;
|
||||
if (string.IsNullOrWhiteSpace(donationId) && session.Metadata.TryGetValue("donation_id", out var metadataId)) donationId = metadataId;
|
||||
var applied = await donations.ApplyStripeStatusAsync(
|
||||
donationId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
|
||||
status, session.PaymentIntentId ?? "", stripeEvent.Id);
|
||||
if (!applied) logger.LogWarning("Stripe event {EventId} did not match donation {DonationId}", stripeEvent.Id, donationId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class DonationStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Paid = "paid";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
|
||||
public sealed class Donation
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = DonationStatus.Pending;
|
||||
[BsonIgnoreIfNull] public string? StripeCheckoutSessionId { get; set; }
|
||||
public string StripePaymentIntentId { get; set; } = "";
|
||||
public string LastStripeEventId { get; set; } = "";
|
||||
public string FailureCode { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? PaidUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateDonationCheckoutRequest
|
||||
{
|
||||
public long AmountCents { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DonationCheckoutResponse
|
||||
{
|
||||
public string DonationId { get; set; } = "";
|
||||
public string CheckoutUrl { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = DonationStatus.Pending;
|
||||
}
|
||||
|
||||
public sealed class DonationStatusResponse
|
||||
{
|
||||
public string DonationId { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = DonationStatus.Pending;
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? PaidUtc { get; set; }
|
||||
|
||||
public static DonationStatusResponse FromModel(Donation donation) => new()
|
||||
{
|
||||
DonationId = donation.Id ?? "",
|
||||
AmountCents = donation.AmountCents,
|
||||
Currency = donation.Currency,
|
||||
Status = donation.Status,
|
||||
CreatedUtc = donation.CreatedUtc,
|
||||
PaidUtc = donation.PaidUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static class DonationValidator
|
||||
{
|
||||
public const long MinimumAmountCents = 500;
|
||||
public const long MaximumAmountCents = 1_000_000;
|
||||
|
||||
public static string? ValidateAmount(long amountCents) => amountCents switch
|
||||
{
|
||||
< MinimumAmountCents => "amountCents must be at least 500",
|
||||
> MaximumAmountCents => "amountCents must be no more than 1000000",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,8 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<DonationStore>();
|
||||
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
|
||||
<PackageReference Include="Stripe.net" Version="52.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"AllowedHosts": "*"
|
||||
|
||||
@@ -35,6 +35,16 @@ spec:
|
||||
secretKeyRef:
|
||||
name: medmind-internal-api
|
||||
key: purge-secret
|
||||
- name: Stripe__SecretKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: medmind-stripe
|
||||
key: secret-key
|
||||
- name: Stripe__WebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: medmind-stripe
|
||||
key: webhook-secret
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
|
||||
Reference in New Issue
Block a user