diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index ad6bd2c2..88562b95 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -34,6 +34,15 @@ public static WebApplicationBuilder ValidateOrbitSecuritySettings(this WebApplic var jwtSettings = builder.Configuration.GetSection(JwtSettings.SectionName).Get(); jwtSettings?.Validate(); + // A blank price ID silently degrades affected users to the wrong currency, so we fail + // fast in deployed environments. Local Development is exempt -- booting the API shouldn't + // require full Stripe price config when you aren't touching checkout. + if (!builder.Environment.IsDevelopment()) + { + var stripeSettings = builder.Configuration.GetSection(StripeSettings.SectionName).Get(); + stripeSettings?.ValidatePriceIds(); + } + if (!builder.Environment.IsProduction()) return builder; @@ -330,6 +339,7 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu // IBillingService wraps every Stripe SDK call used by checkout, portal, plans, // and billing-details so the Application layer has no Stripe imports. builder.Services.AddScoped(); + builder.Services.AddScoped(); // Push Notifications (VAPID + FCM) builder.Services.Configure( diff --git a/src/Orbit.Application/Common/StripeSettings.cs b/src/Orbit.Application/Common/StripeSettings.cs index 6e7f9312..f96deb41 100644 --- a/src/Orbit.Application/Common/StripeSettings.cs +++ b/src/Orbit.Application/Common/StripeSettings.cs @@ -13,4 +13,23 @@ public class StripeSettings public string SuccessUrl { get; set; } = ""; public string CancelUrl { get; set; } = ""; public string ProProductId { get; set; } = ""; + + /// + /// Throws if any of the four checkout price IDs (BRL/USD × monthly/yearly) is missing. + /// A blank ID would silently degrade affected users to the wrong currency, so the API + /// must refuse to boot. Call once at startup. + /// + public void ValidatePriceIds() + { + var missingKeys = new List(); + if (string.IsNullOrWhiteSpace(MonthlyPriceIdUsd)) missingKeys.Add($"{SectionName}:{nameof(MonthlyPriceIdUsd)}"); + if (string.IsNullOrWhiteSpace(YearlyPriceIdUsd)) missingKeys.Add($"{SectionName}:{nameof(YearlyPriceIdUsd)}"); + if (string.IsNullOrWhiteSpace(MonthlyPriceIdBrl)) missingKeys.Add($"{SectionName}:{nameof(MonthlyPriceIdBrl)}"); + if (string.IsNullOrWhiteSpace(YearlyPriceIdBrl)) missingKeys.Add($"{SectionName}:{nameof(YearlyPriceIdBrl)}"); + + if (missingKeys.Count > 0) + throw new InvalidOperationException( + $"Missing required Stripe price ID(s): {string.Join(", ", missingKeys)}. " + + "Set all four BRL/USD monthly/yearly price IDs before starting the API."); + } } diff --git a/src/Orbit.Application/Subscriptions/Commands/CreateCheckoutCommand.cs b/src/Orbit.Application/Subscriptions/Commands/CreateCheckoutCommand.cs index ee609469..31ce436b 100644 --- a/src/Orbit.Application/Subscriptions/Commands/CreateCheckoutCommand.cs +++ b/src/Orbit.Application/Subscriptions/Commands/CreateCheckoutCommand.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orbit.Application.Common; +using Orbit.Application.Subscriptions.Services; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -16,6 +17,7 @@ public partial class CreateCheckoutCommandHandler( IGeoLocationService geoLocationService, IOptions stripeSettings, IBillingService billingService, + IPriceResolver priceResolver, ILogger logger) : IRequestHandler> { private readonly StripeSettings _settings = stripeSettings.Value; @@ -39,14 +41,7 @@ public async Task> Handle(CreateCheckoutCommand request if (string.IsNullOrEmpty(interval) || !allowedIntervals.Contains(interval)) return Result.Failure(ErrorMessages.InvalidBillingInterval, ErrorCodes.InvalidBillingInterval); - var priceId = (interval, isBrazil) switch - { - ("yearly", true) => _settings.YearlyPriceIdBrl, - ("yearly", false) => _settings.YearlyPriceIdUsd, - ("monthly", true) => _settings.MonthlyPriceIdBrl, - ("monthly", false) => _settings.MonthlyPriceIdUsd, - _ => _settings.MonthlyPriceIdBrl - }; + var priceId = priceResolver.Resolve(interval, isBrazil); try { diff --git a/src/Orbit.Application/Subscriptions/Queries/GetPlansQuery.cs b/src/Orbit.Application/Subscriptions/Queries/GetPlansQuery.cs index 634115d7..87566ec9 100644 --- a/src/Orbit.Application/Subscriptions/Queries/GetPlansQuery.cs +++ b/src/Orbit.Application/Subscriptions/Queries/GetPlansQuery.cs @@ -1,7 +1,7 @@ using MediatR; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Orbit.Application.Common; +using Orbit.Application.Subscriptions.Services; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -13,12 +13,10 @@ public record GetPlansQuery(Guid UserId, string? CountryCode, string? IpAddress) public partial class GetPlansQueryHandler( IGenericRepository userRepository, IGeoLocationService geoLocationService, - IOptions stripeSettings, IBillingService billingService, + IPriceResolver priceResolver, ILogger logger) : IRequestHandler> { - private readonly StripeSettings _settings = stripeSettings.Value; - public async Task> Handle(GetPlansQuery request, CancellationToken cancellationToken) { var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); @@ -33,8 +31,8 @@ public async Task> Handle(GetPlansQuery request, Cancellat cancellationToken); var isBrazil = countryCode == "BR"; - var monthlyPriceId = isBrazil ? _settings.MonthlyPriceIdBrl : _settings.MonthlyPriceIdUsd; - var yearlyPriceId = isBrazil ? _settings.YearlyPriceIdBrl : _settings.YearlyPriceIdUsd; + var monthlyPriceId = priceResolver.Resolve("monthly", isBrazil); + var yearlyPriceId = priceResolver.Resolve("yearly", isBrazil); var currency = isBrazil ? "brl" : "usd"; try diff --git a/src/Orbit.Application/Subscriptions/Services/IPriceResolver.cs b/src/Orbit.Application/Subscriptions/Services/IPriceResolver.cs new file mode 100644 index 00000000..e1d939c5 --- /dev/null +++ b/src/Orbit.Application/Subscriptions/Services/IPriceResolver.cs @@ -0,0 +1,17 @@ +namespace Orbit.Application.Subscriptions.Services; + +/// +/// Resolves the Stripe price ID for a billing interval and audience. The country +/// resolver collapses the world to a single isBrazil flag, so the price space +/// is a fixed 2×2 of currency (BRL/USD) × interval (monthly/yearly). Both the checkout +/// command and the plans query share this single mapping so the switch lives in one place. +/// +public interface IPriceResolver +{ + /// + /// Returns the configured Stripe price ID for the given interval and audience. + /// must be "monthly" or "yearly"; + /// callers validate the interval before reaching here. + /// + string Resolve(string interval, bool isBrazil); +} diff --git a/src/Orbit.Application/Subscriptions/Services/PriceResolver.cs b/src/Orbit.Application/Subscriptions/Services/PriceResolver.cs new file mode 100644 index 00000000..7f1d9c66 --- /dev/null +++ b/src/Orbit.Application/Subscriptions/Services/PriceResolver.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Orbit.Application.Common; + +namespace Orbit.Application.Subscriptions.Services; + +// Config stays flat (MonthlyPriceIdBrl / YearlyPriceIdBrl / MonthlyPriceIdUsd / YearlyPriceIdUsd) +// rather than the nested Stripe:Prices:BRL:Annual shape suggested in #78: the audience collapses +// to one isBrazil bool, so the price space is a fixed 2×2 that this resolver fully hides from both +// callers. Nesting would buy no caller-side clarity and would churn StripeSettings, the Render env +// var names, and the existing unit tests for zero behavioral gain. +public sealed class PriceResolver(IOptions stripeSettings) : IPriceResolver +{ + private readonly StripeSettings _settings = stripeSettings.Value; + + public string Resolve(string interval, bool isBrazil) => (interval, isBrazil) switch + { + ("monthly", true) => _settings.MonthlyPriceIdBrl, + ("monthly", false) => _settings.MonthlyPriceIdUsd, + ("yearly", true) => _settings.YearlyPriceIdBrl, + ("yearly", false) => _settings.YearlyPriceIdUsd, + _ => throw new ArgumentOutOfRangeException(nameof(interval), interval, "Interval must be 'monthly' or 'yearly'.") + }; +} diff --git a/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs index dc7951f1..d2fd7c6f 100644 --- a/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs @@ -6,6 +6,7 @@ using NSubstitute.ExceptionExtensions; using Orbit.Application.Common; using Orbit.Application.Subscriptions.Commands; +using Orbit.Application.Subscriptions.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -36,6 +37,7 @@ public CreateCheckoutCommandHandlerTests() _handler = new CreateCheckoutCommandHandler( _userRepo, _unitOfWork, _geoLocationService, settings, _billingService, + new PriceResolver(settings), Substitute.For>()); _geoLocationService.GetCountryCodeAsync(Arg.Any(), Arg.Any()) diff --git a/tests/Orbit.Application.Tests/Common/StripeSettingsTests.cs b/tests/Orbit.Application.Tests/Common/StripeSettingsTests.cs new file mode 100644 index 00000000..c8383fc0 --- /dev/null +++ b/tests/Orbit.Application.Tests/Common/StripeSettingsTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Common; + +public class StripeSettingsTests +{ + private static StripeSettings AllPriceIdsSet() => new() + { + MonthlyPriceIdUsd = "price_monthly_usd", + YearlyPriceIdUsd = "price_yearly_usd", + MonthlyPriceIdBrl = "price_monthly_brl", + YearlyPriceIdBrl = "price_yearly_brl" + }; + + [Fact] + public void ValidatePriceIds_AllSet_DoesNotThrow() + { + var act = () => AllPriceIdsSet().ValidatePriceIds(); + + act.Should().NotThrow(); + } + + [Theory] + [InlineData(nameof(StripeSettings.MonthlyPriceIdUsd))] + [InlineData(nameof(StripeSettings.YearlyPriceIdUsd))] + [InlineData(nameof(StripeSettings.MonthlyPriceIdBrl))] + [InlineData(nameof(StripeSettings.YearlyPriceIdBrl))] + public void ValidatePriceIds_OneBlank_ThrowsNamingMissingKey(string blankProperty) + { + var settings = AllPriceIdsSet(); + typeof(StripeSettings).GetProperty(blankProperty)!.SetValue(settings, " "); + + var act = () => settings.ValidatePriceIds(); + + act.Should().Throw() + .WithMessage($"*{StripeSettings.SectionName}:{blankProperty}*"); + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Subscriptions/GetPlansQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Subscriptions/GetPlansQueryHandlerTests.cs index f91e30d1..87899162 100644 --- a/tests/Orbit.Application.Tests/Queries/Subscriptions/GetPlansQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Subscriptions/GetPlansQueryHandlerTests.cs @@ -5,6 +5,7 @@ using NSubstitute.ExceptionExtensions; using Orbit.Application.Common; using Orbit.Application.Subscriptions.Queries; +using Orbit.Application.Subscriptions.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -30,7 +31,8 @@ public GetPlansQueryHandlerTests() MonthlyPriceIdBrl = "price_monthly_brl", YearlyPriceIdBrl = "price_yearly_brl" }); - _handler = new GetPlansQueryHandler(_userRepo, _geoLocationService, _stripeSettings, _billingService, _logger); + _handler = new GetPlansQueryHandler( + _userRepo, _geoLocationService, _billingService, new PriceResolver(_stripeSettings), _logger); } private static User CreateTestUser() diff --git a/tests/Orbit.Application.Tests/Subscriptions/Services/PriceResolverTests.cs b/tests/Orbit.Application.Tests/Subscriptions/Services/PriceResolverTests.cs new file mode 100644 index 00000000..15b5f67f --- /dev/null +++ b/tests/Orbit.Application.Tests/Subscriptions/Services/PriceResolverTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Orbit.Application.Common; +using Orbit.Application.Subscriptions.Services; + +namespace Orbit.Application.Tests.Subscriptions.Services; + +public class PriceResolverTests +{ + private readonly PriceResolver _resolver = new(Options.Create(new StripeSettings + { + MonthlyPriceIdUsd = "price_monthly_usd", + YearlyPriceIdUsd = "price_yearly_usd", + MonthlyPriceIdBrl = "price_monthly_brl", + YearlyPriceIdBrl = "price_yearly_brl" + })); + + [Theory] + [InlineData("monthly", true, "price_monthly_brl")] + [InlineData("monthly", false, "price_monthly_usd")] + [InlineData("yearly", true, "price_yearly_brl")] + [InlineData("yearly", false, "price_yearly_usd")] + public void Resolve_KnownIntervalAndAudience_ReturnsMatchingPriceId(string interval, bool isBrazil, string expected) + { + _resolver.Resolve(interval, isBrazil).Should().Be(expected); + } + + [Fact] + public void Resolve_UnknownInterval_Throws() + { + var act = () => _resolver.Resolve("weekly", false); + + act.Should().Throw(); + } +} diff --git a/tests/Orbit.IntegrationTests/CapturingBillingService.cs b/tests/Orbit.IntegrationTests/CapturingBillingService.cs new file mode 100644 index 00000000..624ca98c --- /dev/null +++ b/tests/Orbit.IntegrationTests/CapturingBillingService.cs @@ -0,0 +1,49 @@ +using Orbit.Application.Common; + +namespace Orbit.IntegrationTests; + +/// +/// Test double for that removes the live-Stripe dependency from +/// checkout and plans integration tests. It records the price ID passed to checkout (so a test +/// can assert which price the resolver selected) and returns deterministic unit amounts for the +/// two price IDs requested by the plans endpoint. Tests run in the Sequential collection, so the +/// single captured price ID is read back right after each checkout call with no cross-test race. +/// +public sealed class CapturingBillingService : IBillingService +{ + private const long MonthlyUnitAmount = 1990; + private const long YearlyUnitAmount = 19900; + + public string? LastCheckoutPriceId { get; private set; } + + public Task CreateCustomerAsync(string email, string name, Guid userId, CancellationToken cancellationToken) + => Task.FromResult($"cus_test_{userId:N}"); + + public Task CreateCheckoutSessionAsync( + string customerId, + string priceId, + string successUrl, + string cancelUrl, + Guid userId, + string? referralCouponId, + CancellationToken cancellationToken) + { + LastCheckoutPriceId = priceId; + return Task.FromResult($"https://checkout.stripe.test/{priceId}"); + } + + public Task CreatePortalSessionAsync(string customerId, string returnUrl, CancellationToken cancellationToken) + => Task.FromResult("https://portal.stripe.test/session"); + + public Task GetSubscriptionDetailsAsync(string subscriptionId, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> ListInvoicesAsync(string customerId, int limit, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task GetPriceUnitAmountAsync(string priceId, CancellationToken cancellationToken) + => Task.FromResult(priceId.Contains("yearly", StringComparison.Ordinal) ? YearlyUnitAmount : MonthlyUnitAmount); + + public Task TryGetCouponPercentOffAsync(string couponId, CancellationToken cancellationToken) + => Task.FromResult(null); +} diff --git a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs index 7db538e7..6c0bca13 100644 --- a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs +++ b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Orbit.Application.Common; namespace Orbit.IntegrationTests; @@ -7,9 +10,40 @@ public sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory { private static int _clientCounter; + // Connection string, Stripe price IDs, Encryption key, and JWT issuer/audience are validated + // (or consumed by the DbContext) during the builder phase in Program.cs, so they are supplied + // via environment variables read by the default config providers. This keeps the integration + // host hermetic and independent of the gitignored appsettings.Development.json, which is absent + // in git worktrees. The JWT secret is injected separately via UseSetting in ConfigureWebHost. + static IntegrationTestWebApplicationFactory() + { + SetIfMissing("ConnectionStrings__DefaultConnection", "Host=localhost;Port=5432;Database=orbit_test;Username=postgres;Password=postgres"); + SetIfMissing("Jwt__Issuer", "OrbitTestApi"); + SetIfMissing("Jwt__Audience", "OrbitTestClient"); + SetIfMissing("Encryption__Key", "DdyUCjjdK326cB9lY00tyUvRDpCQcYJOJIpu21I1D8c="); + SetIfMissing("Stripe__MonthlyPriceIdUsd", "price_test_monthly_usd"); + SetIfMissing("Stripe__YearlyPriceIdUsd", "price_test_yearly_usd"); + SetIfMissing("Stripe__MonthlyPriceIdBrl", "price_test_monthly_brl"); + SetIfMissing("Stripe__YearlyPriceIdBrl", "price_test_yearly_brl"); + SetIfMissing("Stripe__SuccessUrl", "https://app.test/success"); + SetIfMissing("Stripe__CancelUrl", "https://app.test/cancel"); + } + + /// + /// Captures the price ID passed to checkout and serves fixed plan amounts so billing tests + /// never reach live Stripe. Shared across the Sequential collection; pricing tests read it + /// back immediately after their own request. + /// + public CapturingBillingService BillingService { get; } = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting("Jwt:SecretKey", "OrbitIntegrationTestSecretKey-0123456789-ABCDEF"); + + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => BillingService); + }); } protected override void ConfigureClient(HttpClient client) @@ -20,6 +54,12 @@ protected override void ConfigureClient(HttpClient client) client.DefaultRequestHeaders.TryAddWithoutValidation("X-Forwarded-For", BuildClientIpAddress()); } + private static void SetIfMissing(string name, string value) + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))) + Environment.SetEnvironmentVariable(name, value); + } + private static string BuildClientIpAddress() { var clientNumber = Interlocked.Increment(ref _clientCounter); diff --git a/tests/Orbit.IntegrationTests/SubscriptionPricingIntegrationTests.cs b/tests/Orbit.IntegrationTests/SubscriptionPricingIntegrationTests.cs new file mode 100644 index 00000000..e057eb7c --- /dev/null +++ b/tests/Orbit.IntegrationTests/SubscriptionPricingIntegrationTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class SubscriptionPricingIntegrationTests : IAsyncLifetime +{ + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"pricing-test-{Guid.NewGuid()}@integration.test"; + private const string TestCode = "999999"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public SubscriptionPricingIntegrationTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + // ── Checkout price resolution ───────────────────────────── + + [Theory] + [InlineData("BR", "yearly", "price_test_yearly_brl")] + [InlineData("BR", "monthly", "price_test_monthly_brl")] + [InlineData("US", "yearly", "price_test_yearly_usd")] + [InlineData("US", "monthly", "price_test_monthly_usd")] + public async Task Checkout_ResolvesPriceIdForCountryAndInterval(string countryCode, string interval, string expectedPriceId) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/subscriptions/checkout") + { + Content = JsonContent.Create(new { interval }) + }; + request.Headers.Add("X-Orbit-Country-Code", countryCode); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + _factory.BillingService.LastCheckoutPriceId.Should().Be(expectedPriceId); + } + + // ── Plans currency resolution ───────────────────────────── + + [Theory] + [InlineData("BR", "brl")] + [InlineData("US", "usd")] + public async Task Plans_ReturnsCurrencyForCountry(string countryCode, string expectedCurrency) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/subscriptions/plans"); + request.Headers.Add("X-Orbit-Country-Code", countryCode); + + var response = await _client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var plans = await response.Content.ReadFromJsonAsync(JsonOptions); + plans.Should().NotBeNull(); + plans!.Currency.Should().Be(expectedCurrency); + plans.Monthly.Currency.Should().Be(expectedCurrency); + plans.Yearly.Currency.Should().Be(expectedCurrency); + } + + [Fact] + public async Task Plans_NoCountrySignal_DefaultsToUsd() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Remove("X-Forwarded-For"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Forwarded-For", "10.0.0.1"); + await IntegrationTestHelpers.AuthenticateWithCodeAsync(client, _email, TestCode, JsonOptions); + + var response = await client.GetAsync("/api/subscriptions/plans"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var plans = await response.Content.ReadFromJsonAsync(JsonOptions); + plans!.Currency.Should().Be("usd"); + } + + private record PlanPriceDto(long UnitAmount, string Currency); + private record PlansResponse(PlanPriceDto Monthly, PlanPriceDto Yearly, int SavingsPercent, int? CouponPercentOff, string Currency); +}