Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public static WebApplicationBuilder ValidateOrbitSecuritySettings(this WebApplic
var jwtSettings = builder.Configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>();
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>();
stripeSettings?.ValidatePriceIds();
}

if (!builder.Environment.IsProduction())
return builder;

Expand Down Expand Up @@ -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<Orbit.Application.Common.IBillingService, Orbit.Infrastructure.Services.StripeBillingService>();
builder.Services.AddScoped<Orbit.Application.Subscriptions.Services.IPriceResolver, Orbit.Application.Subscriptions.Services.PriceResolver>();

// Push Notifications (VAPID + FCM)
builder.Services.Configure<VapidSettings>(
Expand Down
19 changes: 19 additions & 0 deletions src/Orbit.Application/Common/StripeSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,23 @@ public class StripeSettings
public string SuccessUrl { get; set; } = "";
public string CancelUrl { get; set; } = "";
public string ProProductId { get; set; } = "";

/// <summary>
/// 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.
/// </summary>
public void ValidatePriceIds()
{
var missingKeys = new List<string>();
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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,7 @@ public partial class CreateCheckoutCommandHandler(
IGeoLocationService geoLocationService,
IOptions<StripeSettings> stripeSettings,
IBillingService billingService,
IPriceResolver priceResolver,
ILogger<CreateCheckoutCommandHandler> logger) : IRequestHandler<CreateCheckoutCommand, Result<CheckoutResponse>>
{
private readonly StripeSettings _settings = stripeSettings.Value;
Expand All @@ -39,14 +41,7 @@ public async Task<Result<CheckoutResponse>> Handle(CreateCheckoutCommand request
if (string.IsNullOrEmpty(interval) || !allowedIntervals.Contains(interval))
return Result.Failure<CheckoutResponse>(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
{
Expand Down
10 changes: 4 additions & 6 deletions src/Orbit.Application/Subscriptions/Queries/GetPlansQuery.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,12 +13,10 @@ public record GetPlansQuery(Guid UserId, string? CountryCode, string? IpAddress)
public partial class GetPlansQueryHandler(
IGenericRepository<User> userRepository,
IGeoLocationService geoLocationService,
IOptions<StripeSettings> stripeSettings,
IBillingService billingService,
IPriceResolver priceResolver,
ILogger<GetPlansQueryHandler> logger) : IRequestHandler<GetPlansQuery, Result<PlansResponse>>
{
private readonly StripeSettings _settings = stripeSettings.Value;

public async Task<Result<PlansResponse>> Handle(GetPlansQuery request, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
Expand All @@ -33,8 +31,8 @@ public async Task<Result<PlansResponse>> 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
Expand Down
17 changes: 17 additions & 0 deletions src/Orbit.Application/Subscriptions/Services/IPriceResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Orbit.Application.Subscriptions.Services;

/// <summary>
/// Resolves the Stripe price ID for a billing interval and audience. The country
/// resolver collapses the world to a single <c>isBrazil</c> 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.
/// </summary>
public interface IPriceResolver
{
/// <summary>
/// Returns the configured Stripe price ID for the given interval and audience.
/// <paramref name="interval"/> must be <c>"monthly"</c> or <c>"yearly"</c>;
/// callers validate the interval before reaching here.
/// </summary>
string Resolve(string interval, bool isBrazil);
}
23 changes: 23 additions & 0 deletions src/Orbit.Application/Subscriptions/Services/PriceResolver.cs
Original file line number Diff line number Diff line change
@@ -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> 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'.")
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -36,6 +37,7 @@ public CreateCheckoutCommandHandlerTests()
_handler = new CreateCheckoutCommandHandler(
_userRepo, _unitOfWork, _geoLocationService, settings,
_billingService,
new PriceResolver(settings),
Substitute.For<ILogger<CreateCheckoutCommandHandler>>());

_geoLocationService.GetCountryCodeAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
Expand Down
39 changes: 39 additions & 0 deletions tests/Orbit.Application.Tests/Common/StripeSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>()
.WithMessage($"*{StripeSettings.SectionName}:{blankProperty}*");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ArgumentOutOfRangeException>();
}
}
49 changes: 49 additions & 0 deletions tests/Orbit.IntegrationTests/CapturingBillingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Orbit.Application.Common;

namespace Orbit.IntegrationTests;

/// <summary>
/// Test double for <see cref="IBillingService"/> 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.
/// </summary>
public sealed class CapturingBillingService : IBillingService
{
private const long MonthlyUnitAmount = 1990;
private const long YearlyUnitAmount = 19900;

public string? LastCheckoutPriceId { get; private set; }

public Task<string> CreateCustomerAsync(string email, string name, Guid userId, CancellationToken cancellationToken)
=> Task.FromResult($"cus_test_{userId:N}");

public Task<string> 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<string> CreatePortalSessionAsync(string customerId, string returnUrl, CancellationToken cancellationToken)
=> Task.FromResult("https://portal.stripe.test/session");

public Task<BillingSubscriptionDetails?> GetSubscriptionDetailsAsync(string subscriptionId, CancellationToken cancellationToken)
=> Task.FromResult<BillingSubscriptionDetails?>(null);

public Task<IReadOnlyList<BillingInvoice>> ListInvoicesAsync(string customerId, int limit, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<BillingInvoice>>([]);

public Task<long> GetPriceUnitAmountAsync(string priceId, CancellationToken cancellationToken)
=> Task.FromResult(priceId.Contains("yearly", StringComparison.Ordinal) ? YearlyUnitAmount : MonthlyUnitAmount);

public Task<int?> TryGetCouponPercentOffAsync(string couponId, CancellationToken cancellationToken)
=> Task.FromResult<int?>(null);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Orbit.Application.Common;

namespace Orbit.IntegrationTests;

public sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory<Program>
{
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");
}

/// <summary>
/// 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.
/// </summary>
public CapturingBillingService BillingService { get; } = new();

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Jwt:SecretKey", "OrbitIntegrationTestSecretKey-0123456789-ABCDEF");

builder.ConfigureTestServices(services =>
{
services.AddScoped<IBillingService>(_ => BillingService);
});
}

protected override void ConfigureClient(HttpClient client)
Expand All @@ -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);
Expand Down
Loading
Loading