diff --git a/.gitea/workflows/deploy-questions.yml b/.gitea/workflows/deploy-questions.yml index b2290a9..1bd44d1 100644 --- a/.gitea/workflows/deploy-questions.yml +++ b/.gitea/workflows/deploy-questions.yml @@ -79,6 +79,18 @@ jobs: --from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \ --dry-run=client -o yaml | kubectl apply -n step3-questions -f - + - name: Configure Stripe secrets + env: + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + run: | + test -n "$STRIPE_SECRET_KEY" + test -n "$STRIPE_WEBHOOK_SECRET" + kubectl create secret generic medmind-stripe \ + --from-literal=secret-key="$STRIPE_SECRET_KEY" \ + --from-literal=webhook-secret="$STRIPE_WEBHOOK_SECRET" \ + --dry-run=client -o yaml | kubectl apply -n step3-questions -f - + - name: Apply Questions deployment & service run: | kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions diff --git a/api/QuestionsApi.Tests/DonationTests.cs b/api/QuestionsApi.Tests/DonationTests.cs new file mode 100644 index 0000000..20ceebc --- /dev/null +++ b/api/QuestionsApi.Tests/DonationTests.cs @@ -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))); + } +} diff --git a/api/QuestionsApi/Controllers/DonationsController.cs b/api/QuestionsApi/Controllers/DonationsController.cs new file mode 100644 index 0000000..5230381 --- /dev/null +++ b/api/QuestionsApi/Controllers/DonationsController.cs @@ -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 logger) : ControllerBase +{ + [HttpPost("checkout"), Authorize(Roles = "USER,SUPER")] + public async Task 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 GetStatus(string id) => + await donations.GetOwnedAsync(id.Trim(), UserId()) is { } donation + ? Ok(DonationStatusResponse.FromModel(donation)) + : NotFound(); + + [HttpPost("stripe-webhook"), AllowAnonymous] + public async Task 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 ?? ""; +} diff --git a/api/QuestionsApi/Models/Donation.cs b/api/QuestionsApi/Models/Donation.cs new file mode 100644 index 0000000..4cf9bb5 --- /dev/null +++ b/api/QuestionsApi/Models/Donation.cs @@ -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 + }; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 7e52ca3..b9d6c5b 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -26,6 +26,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/QuestionsApi.csproj b/api/QuestionsApi/QuestionsApi.csproj index 0cb0950..8f1c9dd 100644 --- a/api/QuestionsApi/QuestionsApi.csproj +++ b/api/QuestionsApi/QuestionsApi.csproj @@ -10,6 +10,7 @@ + diff --git a/api/QuestionsApi/Services/DonationStore.cs b/api/QuestionsApi/Services/DonationStore.cs new file mode 100644 index 0000000..50d2100 --- /dev/null +++ b/api/QuestionsApi/Services/DonationStore.cs @@ -0,0 +1,74 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class DonationStore +{ + private readonly IMongoCollection _donations; + + public DonationStore(MongoDatabaseProvider mongo) + { + _donations = mongo.Database.GetCollection("Donations"); + _donations.Indexes.CreateMany([ + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.StripeCheckoutSessionId), + new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = Builders.Filter.Type(value => value.StripeCheckoutSessionId, MongoDB.Bson.BsonType.String) + }), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)) + ]); + } + + public async Task CreateAsync(string userId, long amountCents) + { + var donation = new Donation { UserId = userId, AmountCents = amountCents }; + await _donations.InsertOneAsync(donation); + return donation; + } + + public async Task 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.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.Update.Set(value => value.Status, DonationStatus.Failed) + .Set(value => value.FailureCode, failureCode).Set(value => value.UpdatedUtc, DateTime.UtcNow)); + + public async Task 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.Filter.Eq(value => value.Id, id) + & Builders.Filter.Eq(value => value.AmountCents, amountCents) + & Builders.Filter.Eq(value => value.Currency, currency.ToLowerInvariant()) + & (Builders.Filter.Eq(value => value.StripeCheckoutSessionId, sessionId) + | Builders.Filter.Eq(value => value.StripeCheckoutSessionId, null)); + + if (status != DonationStatus.Paid) + filter &= Builders.Filter.Eq(value => value.Status, DonationStatus.Pending); + + var now = DateTime.UtcNow; + var update = Builders.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; + } +} diff --git a/api/QuestionsApi/Services/StripeDonationGateway.cs b/api/QuestionsApi/Services/StripeDonationGateway.cs new file mode 100644 index 0000000..4caf229 --- /dev/null +++ b/api/QuestionsApi/Services/StripeDonationGateway.cs @@ -0,0 +1,57 @@ +using Stripe; +using Stripe.Checkout; + +namespace QuestionsApi.Services; + +public sealed record StripeCheckoutResult(string SessionId, string CheckoutUrl); + +public interface IStripeDonationGateway +{ + Task CreateCheckoutAsync(string donationId, long amountCents, CancellationToken cancellationToken); +} + +public sealed class StripeDonationGateway(IConfiguration configuration) : IStripeDonationGateway +{ + public async Task 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 { ["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); + } +} diff --git a/api/QuestionsApi/appsettings.Development.json b/api/QuestionsApi/appsettings.Development.json index b6ce260..abeee2a 100644 --- a/api/QuestionsApi/appsettings.Development.json +++ b/api/QuestionsApi/appsettings.Development.json @@ -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" } } } diff --git a/api/QuestionsApi/appsettings.json b/api/QuestionsApi/appsettings.json index 226f593..d4537a1 100644 --- a/api/QuestionsApi/appsettings.json +++ b/api/QuestionsApi/appsettings.json @@ -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": "*" diff --git a/api/QuestionsApi/k8s/deployment.yaml b/api/QuestionsApi/k8s/deployment.yaml index f5d7294..12767a6 100644 --- a/api/QuestionsApi/k8s/deployment.yaml +++ b/api/QuestionsApi/k8s/deployment.yaml @@ -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 diff --git a/docs/STRIPE_DONATIONS.md b/docs/STRIPE_DONATIONS.md new file mode 100644 index 0000000..2b8140a --- /dev/null +++ b/docs/STRIPE_DONATIONS.md @@ -0,0 +1,56 @@ +# Stripe Donations + +MedMind accepts authenticated, one-time USD contributions through Stripe-hosted Checkout. The API enforces a minimum contribution of $5 and a maximum of $10,000. Payment card data never passes through or is stored by MedMind. + +Contributions are not tax-deductible. + +## Stripe test-mode setup + +1. In the Stripe Dashboard, switch to test mode. +2. Create a webhook endpoint at: + + `https://s3q.ranaze.com/api/donations/stripe-webhook` + +3. Subscribe the endpoint to these events: + + - `checkout.session.completed` + - `checkout.session.async_payment_succeeded` + - `checkout.session.async_payment_failed` + - `checkout.session.expired` + +4. Copy the webhook signing secret (`whsec_...`). +5. Copy a test secret API key (`sk_test_...`). +6. Add both values as Gitea Actions secrets in the API repository: + + - `STRIPE_SECRET_KEY` + - `STRIPE_WEBHOOK_SECRET` + +The deployment workflow writes these values to the Kubernetes secret `medmind-stripe`. Never commit either value to the repository. + +## Stripe account settings + +Configure MedMind branding in Stripe Checkout and enable successful-payment email receipts in Stripe customer email settings. Stripe Checkout collects the contributor's email address. + +## Verification + +After deployment: + +1. Sign in to MedMind and open **Support MedMind** from the sidebar. +2. Confirm that amounts below $5 and above $10,000 are rejected. +3. Start a $5 test contribution. +4. Complete Checkout using Stripe's standard test card `4242 4242 4242 4242`, any future expiration date, and any CVC. +5. Return to MedMind and confirm the contribution changes from pending to confirmed. +6. In Stripe, confirm the Checkout Session and webhook delivery both succeeded. +7. In MongoDB, confirm the corresponding `Donations` document has `Status: "paid"` and a populated `PaidUtc` value. + +The client-visible status endpoint returns only the donation ID, amount, currency, status, creation time, and paid time. Stripe payment identifiers and webhook metadata remain server-side. + +## Live-mode cutover + +1. Create the same webhook endpoint in Stripe live mode. +2. Replace `STRIPE_SECRET_KEY` with the live secret key (`sk_live_...`). +3. Replace `STRIPE_WEBHOOK_SECRET` with the live endpoint's signing secret. +4. Trigger a deployment and complete a small real contribution. +5. Confirm the payment, receipt, webhook delivery, and `paid` status before announcing availability. + +Test and live webhook signing secrets are different. Both Stripe secrets must always belong to the same Stripe mode. diff --git a/project.godot b/project.godot index b1d54b6..9fd353a 100644 --- a/project.godot +++ b/project.godot @@ -33,6 +33,7 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" +DonationService="*res://scripts/autoload/donation_service.gd" [display] @@ -60,5 +61,6 @@ app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" notes_api_url="https://s3q.ranaze.com/api/notes" dashboard_api_url="https://s3q.ranaze.com/api/dashboard" profile_api_url="https://s3q.ranaze.com/api/profile" +donations_api_url="https://s3q.ranaze.com/api/donations" review_queue_api_url="https://s3q.ranaze.com/api/review-queue" build_revision="development" diff --git a/scripts/autoload/donation_service.gd b/scripts/autoload/donation_service.gd new file mode 100644 index 0000000..08761f8 --- /dev/null +++ b/scripts/autoload/donation_service.gd @@ -0,0 +1,176 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_URL := "https://s3q.ranaze.com/api/donations" +const REQUEST_TIMEOUT_SECONDS := 30.0 +const POLL_INTERVAL_SECONDS := 3.0 +const POLL_LIMIT := 200 + +signal checkout_started(donation_id: String, checkout_url: String) +signal donation_status_changed(status: String, donation: Dictionary) +signal donation_failed(message: String) + +var api_url := DEFAULT_URL +var checkout_request: HTTPRequest +var status_request: HTTPRequest +var poll_timer: Timer +var request_token := "" +var active_donation_id := "" +var poll_count := 0 +var checkout_pending := false +var status_pending := false + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/donations_api_url", DEFAULT_URL)), + "/api/donations") + checkout_request = HTTPRequest.new() + checkout_request.timeout = REQUEST_TIMEOUT_SECONDS + checkout_request.request_completed.connect(_on_checkout_completed) + add_child(checkout_request) + status_request = HTTPRequest.new() + status_request.timeout = REQUEST_TIMEOUT_SECONDS + status_request.request_completed.connect(_on_status_completed) + add_child(status_request) + poll_timer = Timer.new() + poll_timer.wait_time = POLL_INTERVAL_SECONDS + poll_timer.timeout.connect(_poll_status) + add_child(poll_timer) + AuthState.auth_changed.connect(_on_auth_changed) + + +func begin_checkout(amount_cents: int) -> void: + if checkout_pending: + return + if not AuthState.can_send_authenticated_request(): + donation_failed.emit("Log in before making a contribution.") + return + _prepare_browser_window() + checkout_pending = true + request_token = AuthState.access_token + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % request_token + ]) + var error := checkout_request.request( + "%s/checkout" % api_url.rstrip("/"), headers, HTTPClient.METHOD_POST, + JSON.stringify({"amountCents": amount_cents})) + if error != OK: + checkout_pending = false + _close_browser_window() + donation_failed.emit("The secure checkout request could not be started.") + + +func stop_polling() -> void: + poll_timer.stop() + poll_count = 0 + if status_pending and status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + status_request.cancel_request() + status_pending = false + + +func resume_polling() -> void: + if active_donation_id.is_empty() or poll_timer == null: + return + poll_timer.start() + _poll_status() + + +func _on_checkout_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + checkout_pending = false + if response_code == 401: + AuthState.handle_unauthorized(request_token) + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + _close_browser_window() + donation_failed.emit(_failure_message("Checkout could not be started.", result, response_code, body)) + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + _close_browser_window() + donation_failed.emit("The donation service returned an invalid response.") + return + var donation_id := str(parsed.get("donationId", "")) + var checkout_url := str(parsed.get("checkoutUrl", "")) + if donation_id.is_empty() or not checkout_url.begins_with("https://"): + _close_browser_window() + donation_failed.emit("The donation service returned an invalid checkout link.") + return + active_donation_id = donation_id + _open_checkout_url(checkout_url) + checkout_started.emit(donation_id, checkout_url) + poll_count = 0 + poll_timer.start() + _poll_status() + + +func _poll_status() -> void: + if status_pending or active_donation_id.is_empty() or not AuthState.can_send_authenticated_request(): + return + poll_count += 1 + if poll_count > POLL_LIMIT: + stop_polling() + return + status_pending = true + request_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % request_token]) + var error := status_request.request("%s/%s" % [api_url.rstrip("/"), active_donation_id], headers) + if error != OK: + status_pending = false + + +func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + status_pending = false + if response_code == 401: + AuthState.handle_unauthorized(request_token) + stop_polling() + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + return + var status := str(parsed.get("status", "")) + donation_status_changed.emit(status, parsed) + if status in ["paid", "failed", "expired"]: + stop_polling() + + +func _prepare_browser_window() -> void: + if OS.has_feature("web"): + JavaScriptBridge.eval( + "window.medmindDonationWindow = window.open('about:blank', 'medmind_donation_checkout');", + true) + + +func _open_checkout_url(url: String) -> void: + if OS.has_feature("web"): + JavaScriptBridge.eval( + "if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { window.medmindDonationWindow.location.href = %s; window.medmindDonationWindow.focus(); } else { window.open(%s, '_blank'); }" % [JSON.stringify(url), JSON.stringify(url)], + true) + else: + OS.shell_open(url) + + +func _close_browser_window() -> void: + if OS.has_feature("web"): + JavaScriptBridge.eval( + "if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { window.medmindDonationWindow.close(); } window.medmindDonationWindow = null;", + true) + + +func _failure_message(prefix: String, result: int, response_code: int, body: PackedByteArray) -> String: + if result != HTTPRequest.RESULT_SUCCESS and response_code == 0: + return "%s Check your connection and try again." % prefix + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) == TYPE_DICTIONARY: + var detail := str(parsed.get("detail", "")).strip_edges() + if not detail.is_empty(): + return detail + return "%s Status %d." % [prefix, response_code] + + +func _on_auth_changed() -> void: + if not AuthState.is_logged_in: + stop_polling() + active_donation_id = "" diff --git a/scripts/autoload/donation_service.gd.uid b/scripts/autoload/donation_service.gd.uid new file mode 100644 index 0000000..5fefc6e --- /dev/null +++ b/scripts/autoload/donation_service.gd.uid @@ -0,0 +1 @@ +uid://b20e6ch7a4dtb diff --git a/scripts/ui/donation_amount.gd b/scripts/ui/donation_amount.gd new file mode 100644 index 0000000..aa813ba --- /dev/null +++ b/scripts/ui/donation_amount.gd @@ -0,0 +1,18 @@ +extends RefCounted + + +static func parse_cents(value: String) -> int: + var cleaned := value.strip_edges().trim_prefix("$").replace(",", "") + var matcher := RegEx.new() + matcher.compile("^[0-9]+(?:\\.[0-9]{1,2})?$") + if matcher.search(cleaned) == null: + return -1 + var parts := cleaned.split(".") + var dollars := int(parts[0]) + var cents := 0 + if parts.size() == 2: + var fraction := str(parts[1]) + cents = int(fraction) * (10 if fraction.length() == 1 else 1) + if dollars > 10000 or (dollars == 10000 and cents > 0): + return -1 + return dollars * 100 + cents diff --git a/scripts/ui/donation_amount.gd.uid b/scripts/ui/donation_amount.gd.uid new file mode 100644 index 0000000..9fac093 --- /dev/null +++ b/scripts/ui/donation_amount.gd.uid @@ -0,0 +1 @@ +uid://2ha4l3mmlb61 diff --git a/scripts/ui/donation_screen.gd b/scripts/ui/donation_screen.gd new file mode 100644 index 0000000..48de962 --- /dev/null +++ b/scripts/ui/donation_screen.gd @@ -0,0 +1,316 @@ +extends MarginContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") +const DonationAmount := preload("res://scripts/ui/donation_amount.gd") +const MINIMUM_CENTS := 500 +const MAXIMUM_CENTS := 1000000 +const DEFAULT_CENTS := 2500 + +var main: Control +var content_grid: GridContainer +var mission_panel: PanelContainer +var contribution_panel: PanelContainer +var preset_grid: GridContainer +var amount_buttons: Array[Button] = [] +var amount_group := ButtonGroup.new() +var custom_amount: LineEdit +var donate_button: Button +var status_label: Label +var selected_amount_cents := DEFAULT_CENTS +var donation_service: Node + + +func _ready() -> void: + donation_service = get_node("/root/DonationService") + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("margin_bottom", 8) + _build_ui() + resized.connect(_update_layout) + donation_service.checkout_started.connect(_on_checkout_started) + donation_service.donation_status_changed.connect(_on_donation_status_changed) + donation_service.donation_failed.connect(_on_donation_failed) + UIThemeService.theme_changed.connect(_on_theme_changed) + _update_layout() + _update_donate_button() + refresh_theme_controls() + + +func _build_ui() -> void: + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + + var page := VBoxContainer.new() + page.custom_minimum_size = Vector2(0, 0) + page.size_flags_horizontal = Control.SIZE_EXPAND_FILL + page.add_theme_constant_override("separation", 24) + scroll.add_child(page) + + var title := Label.new() + title.text = "Support MedMind" + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 36) + page.add_child(title) + + var subtitle := Label.new() + subtitle.text = "Help us build accessible, clinically grounded tools for Step 3 learners." + subtitle.add_theme_font_override("font", Brand.font) + subtitle.add_theme_font_size_override("font_size", 18) + subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + page.add_child(subtitle) + + content_grid = GridContainer.new() + content_grid.columns = 2 + content_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_grid.add_theme_constant_override("h_separation", 24) + content_grid.add_theme_constant_override("v_separation", 24) + page.add_child(content_grid) + + mission_panel = _panel() + mission_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_grid.add_child(mission_panel) + var mission := VBoxContainer.new() + mission.add_theme_constant_override("separation", 18) + mission_panel.add_child(mission) + mission.add_child(_heading("Our mission", 26)) + mission.add_child(_body("MedMind exists to make rigorous, clinically grounded medical education more accessible. We are building an independent Step 3 study experience that helps learners develop clinical judgment through original questions, realistic CCS cases, transparent explanations, and continuous improvement driven by learner feedback.")) + mission.add_child(HSeparator.new()) + mission.add_child(_heading("What your support makes possible", 20)) + for item_text in [ + "Original question and CCS case development", + "Clinical source review and content verification", + "Reliable hosting and continued pre-alpha access", + "Accessibility, usability, and learning-tool improvements" + ]: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + var marker := Label.new() + marker.text = "-" + marker.add_theme_font_size_override("font_size", 20) + marker.add_theme_color_override("font_color", Brand.TEAL) + row.add_child(marker) + var item := _body(item_text) + item.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(item) + mission.add_child(row) + + contribution_panel = _panel() + contribution_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_grid.add_child(contribution_panel) + var contribution := VBoxContainer.new() + contribution.add_theme_constant_override("separation", 18) + contribution_panel.add_child(contribution) + contribution.add_child(_heading("Make a one-time contribution", 26)) + contribution.add_child(_body("Choose an amount. Secure payment and email receipts are handled by Stripe.")) + + preset_grid = GridContainer.new() + preset_grid.columns = 4 + preset_grid.add_theme_constant_override("h_separation", 10) + preset_grid.add_theme_constant_override("v_separation", 10) + contribution.add_child(preset_grid) + for amount in [500, 1000, 2500, 5000]: + var button := Button.new() + button.text = "$%d" % int(amount / 100.0) + button.toggle_mode = true + button.button_group = amount_group + button.custom_minimum_size = Vector2(64, 48) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.set_meta("amount_cents", amount) + button.pressed.connect(_select_preset.bind(button)) + preset_grid.add_child(button) + amount_buttons.append(button) + if amount == DEFAULT_CENTS: + button.set_pressed_no_signal(true) + + var custom_label := Label.new() + custom_label.text = "Custom amount (USD)" + custom_label.add_theme_font_override("font", Brand.font) + custom_label.add_theme_font_size_override("font_size", 16) + contribution.add_child(custom_label) + + custom_amount = LineEdit.new() + custom_amount.placeholder_text = "$5.00 minimum" + custom_amount.max_length = 12 + custom_amount.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER_DECIMAL + custom_amount.custom_minimum_size = Vector2(0, 56) + custom_amount.text_changed.connect(_on_custom_amount_changed) + contribution.add_child(custom_amount) + + donate_button = Button.new() + donate_button.text = "Donate $25 securely" + donate_button.custom_minimum_size = Vector2(0, 58) + donate_button.pressed.connect(_on_donate_pressed) + contribution.add_child(donate_button) + + status_label = Label.new() + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + status_label.add_theme_font_override("font", Brand.font) + status_label.add_theme_font_size_override("font_size", 15) + status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + status_label.custom_minimum_size.y = 44 + contribution.add_child(status_label) + + var disclosure := _body("Contributions are voluntary, one-time, and not tax-deductible. Card details are entered only on Stripe's secure checkout page.") + disclosure.add_theme_font_size_override("font_size", 14) + contribution.add_child(disclosure) + + +func on_show(_payload: Variant = null) -> void: + donate_button.disabled = donation_service.checkout_pending + if donation_service.active_donation_id.is_empty(): + status_label.text = "" + else: + donation_service.resume_polling() + + +func on_hide() -> void: + donation_service.stop_polling() + + +static func parse_amount_cents(value: String) -> int: + return DonationAmount.parse_cents(value) + + +func _select_preset(button: Button) -> void: + selected_amount_cents = int(button.get_meta("amount_cents", DEFAULT_CENTS)) + custom_amount.text = "" + _set_validation_state("") + _update_donate_button() + refresh_theme_controls() + + +func _on_custom_amount_changed(value: String) -> void: + if value.strip_edges().is_empty(): + return + for button in amount_buttons: + button.set_pressed_no_signal(false) + selected_amount_cents = parse_amount_cents(value) + _set_validation_state("") + _update_donate_button() + refresh_theme_controls() + + +func _on_donate_pressed() -> void: + if selected_amount_cents < MINIMUM_CENTS: + _set_validation_state("Enter an amount of at least $5.00.", true) + return + if selected_amount_cents > MAXIMUM_CENTS: + _set_validation_state("The maximum contribution is $10,000.00.", true) + return + donate_button.disabled = true + _set_validation_state("Opening secure checkout...") + donation_service.begin_checkout(selected_amount_cents) + + +func _on_checkout_started(_donation_id: String, _checkout_url: String) -> void: + donate_button.disabled = false + _set_validation_state("Checkout opened in your browser. This page will confirm when Stripe completes the payment.") + + +func _on_donation_status_changed(status: String, _donation: Dictionary) -> void: + match status: + "paid": _set_validation_state("Thank you for supporting MedMind. Your contribution has been confirmed.", false, Brand.SUCCESS) + "failed": _set_validation_state("Stripe could not complete the contribution. No payment was confirmed.", true) + "expired": _set_validation_state("The checkout session expired. Start a new checkout when you are ready.", true) + + +func _on_donation_failed(message: String) -> void: + donate_button.disabled = false + _set_validation_state(message, true) + + +func _update_donate_button() -> void: + donate_button.disabled = selected_amount_cents < MINIMUM_CENTS or selected_amount_cents > MAXIMUM_CENTS + if selected_amount_cents >= MINIMUM_CENTS and selected_amount_cents <= MAXIMUM_CENTS: + donate_button.text = "Donate %s securely" % _format_amount(selected_amount_cents) + else: + donate_button.text = "Donate securely" + + +func _format_amount(amount_cents: int) -> String: + return "$%d.%02d" % [int(amount_cents / 100.0), amount_cents % 100] + + +func _set_validation_state(message: String, is_error: bool = false, color: Color = Color.TRANSPARENT) -> void: + status_label.text = message + var text_color := color + if text_color == Color.TRANSPARENT: + text_color = Style.RED if is_error else Brand.muted_color(UIThemeService.is_dark_mode()) + status_label.add_theme_color_override("font_color", text_color) + + +func _panel() -> PanelContainer: + var panel := PanelContainer.new() + var box := Brand.content_card_box(UIThemeService.is_dark_mode(), false) + box.content_margin_left = 28 + box.content_margin_right = 28 + box.content_margin_top = 28 + box.content_margin_bottom = 28 + panel.add_theme_stylebox_override("panel", box) + return panel + + +func _heading(value: String, font_size: int) -> Label: + var label := Label.new() + label.text = value + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", font_size) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func _body(value: String) -> Label: + var label := Label.new() + label.text = value + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", 17) + label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func _update_layout() -> void: + if content_grid != null: + content_grid.columns = 1 if size.x < 1050.0 else 2 + if preset_grid != null: + preset_grid.columns = 2 if size.x < 520.0 else 4 + + +func refresh_theme_controls() -> void: + var dark := UIThemeService.is_dark_mode() + for panel in [mission_panel, contribution_panel]: + if panel != null: + var box := Brand.content_card_box(dark, false) + box.content_margin_left = 28 + box.content_margin_right = 28 + box.content_margin_top = 28 + box.content_margin_bottom = 28 + panel.add_theme_stylebox_override("panel", box) + if custom_amount != null: + custom_amount.add_theme_stylebox_override("normal", Brand.field_box(dark)) + custom_amount.add_theme_stylebox_override("focus", Brand.field_box(dark, true)) + custom_amount.add_theme_color_override("font_color", Brand.text_color(dark)) + custom_amount.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) + for button in amount_buttons: + var selected := button.button_pressed + var normal := Brand.secondary_button_box(dark, selected) + if selected: + normal.bg_color = Color(Brand.ROYAL_BLUE, 0.22 if dark else 0.10) + normal.border_color = Brand.ROYAL_BLUE + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("pressed", normal) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + button.add_theme_color_override("font_pressed_color", Brand.text_color(dark)) + if donate_button != null: + donate_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + donate_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + donate_button.add_theme_color_override("font_color", Color.WHITE) + donate_button.add_theme_color_override("font_hover_color", Color.WHITE) + + +func _on_theme_changed(_is_dark_mode: bool) -> void: + refresh_theme_controls() diff --git a/scripts/ui/donation_screen.gd.uid b/scripts/ui/donation_screen.gd.uid new file mode 100644 index 0000000..38dd1a0 --- /dev/null +++ b/scripts/ui/donation_screen.gd.uid @@ -0,0 +1 @@ +uid://i2fix2vkf8au diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 625c385..2ddff82 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -314,6 +314,11 @@ func _build_ui() -> void: bookmarks.main = self screens_container.add_child(bookmarks) screens["bookmarks"] = bookmarks + var donations := preload("res://scripts/ui/donation_screen.gd").new() + donations.name = "DonationScreen" + donations.main = self + screens_container.add_child(donations) + screens["donations"] = donations feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" @@ -386,6 +391,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL container.add_child(spacer) container.add_child(HSeparator.new()) + _add_nav_button(container, "donations", "Support MedMind", "heart-pulse", true) _add_nav_button(container, "performance", "Profile", "user-round", true) _add_nav_button(container, "settings", "Settings", "settings", true) _add_nav_button(container, "logout", "Log out", "log-out", true) @@ -426,6 +432,7 @@ func _on_navigation_pressed(key: String) -> void: "settings": show_screen("settings") "notes": show_screen("notes") "bookmarks": show_screen("bookmarks") + "donations": show_screen("donations") "logout": _logout() @@ -440,7 +447,8 @@ func _update_header_title(screen_name: String) -> void: "profile": "Profile", "settings": "Settings", "notes": "Notes", - "bookmarks": "Bookmarks" + "bookmarks": "Bookmarks", + "donations": "Support MedMind" } header_title.text = str(titles.get(screen_name, "")) @@ -735,6 +743,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void: var selector = screens.get("selection") if selector != null and selector.has_method("refresh_theme_controls"): selector.refresh_theme_controls() + var donations = screens.get("donations") + if donations != null and donations.has_method("refresh_theme_controls"): + donations.refresh_theme_controls() var player = screens.get("player") if player != null and player.has_method("refresh_theme_controls"): player.refresh_theme_controls() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 262fc0b..0c53738 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -23,4 +23,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "notes" elif screen_name == "bookmarks": key = "bookmarks" + elif screen_name == "donations": + key = "donations" return key diff --git a/tests/donation_screen_test.gd b/tests/donation_screen_test.gd new file mode 100644 index 0000000..fa9bb7c --- /dev/null +++ b/tests/donation_screen_test.gd @@ -0,0 +1,21 @@ +extends SceneTree + +const DonationAmount := preload("res://scripts/ui/donation_amount.gd") + +var failures := 0 + + +func _initialize() -> void: + _assert(DonationAmount.parse_cents("5") == 500, "whole dollars should parse") + _assert(DonationAmount.parse_cents("$25.50") == 2550, "dollars and cents should parse") + _assert(DonationAmount.parse_cents("4.999") == -1, "more than two decimal places should fail") + _assert(DonationAmount.parse_cents("10000.01") == -1, "amounts above the cap should fail") + _assert(DonationAmount.parse_cents("abc") == -1, "non-numeric values should fail") + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/donation_screen_test.gd.uid b/tests/donation_screen_test.gd.uid new file mode 100644 index 0000000..59c6445 --- /dev/null +++ b/tests/donation_screen_test.gd.uid @@ -0,0 +1 @@ +uid://d0ik8fhaa2u8w diff --git a/tests/donation_ui_test.gd b/tests/donation_ui_test.gd new file mode 100644 index 0000000..5db77fa --- /dev/null +++ b/tests/donation_ui_test.gd @@ -0,0 +1,47 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var theme_service = root.get_node("UIThemeService") + var prior_dark_mode: bool = theme_service.is_dark_mode() + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + var screen = load("res://scripts/ui/donation_screen.gd").new() + screen.size = host.size + host.add_child(screen) + await process_frame + _assert(screen.content_grid.columns == 2, "wide donation layouts should use two columns") + _assert(screen.selected_amount_cents == 2500, "the default preset should be $25") + _assert(screen.donate_button.text.contains("$25.00"), "the donation action should state the selected amount") + screen.size = Vector2(760, 800) + screen._update_layout() + _assert(screen.content_grid.columns == 1, "narrow donation layouts should stack") + screen.size = Vector2(360, 800) + screen._update_layout() + _assert(screen.preset_grid.columns == 2, "phone donation layouts should use a two-column amount grid") + screen.custom_amount.text = "4" + screen._on_custom_amount_changed("4") + await process_frame + _assert(screen.donate_button.disabled, "amounts below $5 should disable checkout") + theme_service.set_dark_mode(false) + await process_frame + var light_box := screen.mission_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_box.bg_color.is_equal_approx(Color.WHITE), "donation cards should use light surfaces in light mode") + host.queue_free() + await process_frame + theme_service.set_dark_mode(prior_dark_mode) + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/donation_ui_test.gd.uid b/tests/donation_ui_test.gd.uid new file mode 100644 index 0000000..0f3fe43 --- /dev/null +++ b/tests/donation_ui_test.gd.uid @@ -0,0 +1 @@ +uid://cfg0oxxrtumdj diff --git a/tests/shell_navigation_test.gd b/tests/shell_navigation_test.gd index ad9a4d2..8ca8a57 100644 --- a/tests/shell_navigation_test.gd +++ b/tests/shell_navigation_test.gd @@ -19,6 +19,7 @@ func _initialize() -> void: _assert(NavigationRoutes.key_for_screen("settings") == "settings", "settings should activate Settings") _assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should activate Notes") _assert(NavigationRoutes.key_for_screen("bookmarks") == "bookmarks", "bookmarks should activate Bookmarks") + _assert(NavigationRoutes.key_for_screen("donations") == "donations", "donations should activate Support MedMind") quit(1 if failures > 0 else 0) diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index 40623e2..e091b46 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -74,6 +74,8 @@ func _run() -> void: _assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item") var profile_nav := main_screen.sidebar.find_child("NavPerformance", true, false) as Button var settings_nav := main_screen.sidebar.find_child("NavSettings", true, false) as Button + var donation_nav := main_screen.sidebar.find_child("NavDonations", true, false) as Button + _assert(donation_nav != null and donation_nav.text == "Support MedMind", "the shell should expose donation navigation") _assert(profile_nav != null and settings_nav != null and profile_nav.get_index() + 1 == settings_nav.get_index(), "Profile should sit directly above Settings in the lower navigation group") var theme_service = root.get_node("UIThemeService") var prior_dark_mode: bool = theme_service.is_dark_mode() diff --git a/web/Dockerfile b/web/Dockerfile index 8ea3e0b..ff9e6c3 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -35,9 +35,13 @@ RUN mkdir -p /build \ && godot --verbose --headless --path /src --export-release Web /build/index.html \ && cp /src/assets/branding/medmind-loader-mark.png /build/medmind-loader-icon.png \ && cp /src/assets/fonts/Inter-Variable.ttf /build/medmind-inter.ttf \ + && cp /src/web/donation-complete.html /build/donation-complete.html \ + && cp /src/web/donation-canceled.html /build/donation-canceled.html \ && test -s /build/index.html \ && test -s /build/medmind-loader-icon.png \ && test -s /build/medmind-inter.ttf \ + && test -s /build/donation-complete.html \ + && test -s /build/donation-canceled.html \ && test -s /build/index.wasm \ && test -s /build/index.pck diff --git a/web/donation-canceled.html b/web/donation-canceled.html new file mode 100644 index 0000000..7a75d7e --- /dev/null +++ b/web/donation-canceled.html @@ -0,0 +1,19 @@ + + + + + + Checkout canceled | MedMind + + + +
MedMind

Checkout canceled

No contribution was completed. You can close this tab and return to MedMind.

+ diff --git a/web/donation-complete.html b/web/donation-complete.html new file mode 100644 index 0000000..93bcf6e --- /dev/null +++ b/web/donation-complete.html @@ -0,0 +1,19 @@ + + + + + + Contribution received | MedMind + + + +
MedMind

Thank you for supporting MedMind

Your checkout is complete.

You can close this tab and return to MedMind. Stripe will send your receipt by email.

+