test(billing): prove downgrade lifecycle
CI and Deploy / test (pull_request) Successful in 5m27s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 20:28:23 +02:00
parent 25a6da951e
commit 191de69c48
9 changed files with 217 additions and 17 deletions
+2 -2
View File
@@ -5,10 +5,10 @@ Updated: 2026-08-15
## Stripe billing
- **Blocked:** Activating roadmap item 7.5 in production.
- **Why:** Hosted Checkout, customer-portal sessions, signed subscription webhooks, persisted billing state, and Pro-role provisioning are implemented. The Stripe product, recurring price, portal, webhook registration, and production credentials must be created outside the repository.
- **Why:** Hosted Checkout, customer-portal sessions, signed subscription webhooks, persisted billing state, Pro-role provisioning and a mock lifecycle regression are implemented. The Stripe recurring price, portal, webhook registration and production credentials must be configured outside the repository.
- **Required:** Configure the Pro recurring price, enable the Stripe customer portal, register `/api/billing/webhook` for `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`, then supply `STRIPE_SECRET_KEY`, the recurring `price_...` value in the legacy-named `STRIPE_PRICE_PREMIUM` setting, and `STRIPE_WEBHOOK_SECRET` through the deployment environment. Do not place secret values in source control or chat.
- **Recommended:** One monthly Pro price first; add annual billing only after the monthly flow is operating.
- **Current access check:** Production has test-mode secret and webhook values, but `STRIPE_PRICE_PREMIUM` currently contains a `prod_...` Product ID. Checkout requires the recurring `price_...` Price ID. The publishable key is not used by hosted Checkout.
- **Current access check:** Production has test-mode secret and webhook values, but `STRIPE_PRICE_PREMIUM` currently contains a `prod_...` Product ID. Checkout requires the recurring `price_...` Price ID; the server now treats the wrong identifier type as disabled rather than calling Stripe. The publishable key is not used by hosted Checkout. Local fake-gateway coverage proves active → expired → canceled/replayed role transitions without losing non-AI data (V-185).
- **Runbook:** Follow `docs/operations/stripe-activation.md`, completing test mode before creating or installing live-mode values.
## Document parser dependency and isolation
@@ -1,6 +1,9 @@
using System.Text;
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -8,12 +11,110 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Stripe;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class BillingControllerTests
{
[Fact]
public async Task Checkout_uses_the_configured_price_and_stable_user_metadata()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.test" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
var gateway = new Mock<IStripeBillingGateway>();
Stripe.Checkout.SessionCreateOptions? captured = null;
gateway.Setup(item => item.CreateCheckoutAsync("sk_test_fake", It.IsAny<Stripe.Checkout.SessionCreateOptions>(), It.IsAny<CancellationToken>()))
.Callback((string _, Stripe.Checkout.SessionCreateOptions options, CancellationToken _) => captured = options)
.ReturnsAsync(new Stripe.Checkout.Session { Url = "https://checkout.stripe.test/session" });
var controller = Controller(Configuration(), users, CreateRoleManager(), gateway);
Authenticate(controller, user.Id);
var action = await controller.Checkout(default);
var result = Assert.IsType<OkObjectResult>(action.Result);
Assert.Equal("https://checkout.stripe.test/session", Assert.IsType<BillingController.BillingRedirectDto>(result.Value).Url);
Assert.NotNull(captured);
Assert.Equal("price_fake", Assert.Single(captured.LineItems).Price);
Assert.Equal(user.Id, captured.ClientReferenceId);
Assert.Equal(user.Id, captured.Metadata["jobtracker_user_id"]);
Assert.Equal(user.Id, captured.SubscriptionData.Metadata["jobtracker_user_id"]);
Assert.Equal("https://example.test/settings?billing=success", captured.SuccessUrl);
}
[Fact]
public async Task Product_identifier_cannot_enable_checkout_as_a_price()
{
var user = new ApplicationUser { Id = "user-1" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
var configuration = Configuration(new Dictionary<string, string?> { ["Stripe:PricePremium"] = "prod_wrong_kind" });
var controller = Controller(configuration, users, CreateRoleManager(), new Mock<IStripeBillingGateway>());
Authenticate(controller, user.Id);
var status = Assert.IsType<OkObjectResult>((await controller.Status(default)).Result);
Assert.False(Assert.IsType<BillingController.BillingStatusDto>(status.Value).Enabled);
Assert.IsType<ObjectResult>((await controller.Checkout(default)).Result);
}
[Fact]
public async Task Signed_subscription_lifecycle_grants_then_revokes_on_expiry_idempotently()
{
var user = new ApplicationUser
{
Id = "user-1",
AiEnabled = true,
ProfileCvText = "non-AI profile data must survive",
};
var users = TestHostFactory.CreateUserManager(user);
users.Setup(item => item.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var hasPremium = false;
users.Setup(item => item.IsInRoleAsync(user, "Premium")).ReturnsAsync(() => hasPremium);
users.Setup(item => item.AddToRoleAsync(user, "Premium"))
.Callback(() => hasPremium = true)
.ReturnsAsync(IdentityResult.Success);
users.Setup(item => item.RemoveFromRoleAsync(user, "Premium"))
.Callback(() => hasPremium = false)
.ReturnsAsync(IdentityResult.Success);
var roles = CreateRoleManager();
roles.Setup(item => item.RoleExistsAsync("Premium")).ReturnsAsync(true);
var webhookEvent = new Event
{
Type = EventTypes.CustomerSubscriptionUpdated,
Created = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc),
Data = new EventData { Object = new Subscription { Id = "sub_lifecycle" } },
};
var gateway = new Mock<IStripeBillingGateway>();
gateway.Setup(item => item.ConstructEvent(It.IsAny<string>(), "signed", "whsec_fake")).Returns(webhookEvent);
gateway.SetupSequence(item => item.GetSubscriptionAsync("sk_test_fake", "sub_lifecycle", It.IsAny<CancellationToken>()))
.ReturnsAsync(Subscription("active"))
.ReturnsAsync(Subscription("past_due"))
.ReturnsAsync(Subscription("canceled"));
var controller = Controller(Configuration(), users, roles, gateway);
SetWebhookRequest(controller);
Assert.IsType<OkResult>(await controller.Webhook(default));
Assert.True(hasPremium);
Assert.Equal("active", user.StripeSubscriptionStatus);
SetWebhookRequest(controller);
Assert.IsType<OkResult>(await controller.Webhook(default));
Assert.False(hasPremium);
Assert.Equal("past_due", user.StripeSubscriptionStatus);
Assert.Equal("non-AI profile data must survive", user.ProfileCvText);
SetWebhookRequest(controller);
Assert.IsType<OkResult>(await controller.Webhook(default));
Assert.False(hasPremium);
Assert.Equal("canceled", user.StripeSubscriptionStatus);
users.Verify(item => item.AddToRoleAsync(user, "Premium"), Times.Once);
users.Verify(item => item.RemoveFromRoleAsync(user, "Premium"), Times.Once);
users.Verify(item => item.UpdateAsync(user), Times.Exactly(3));
}
[Fact]
public async Task Webhook_rejects_an_invalid_Stripe_signature()
{
@@ -46,4 +147,64 @@ public sealed class BillingControllerTests
Assert.IsType<BadRequestObjectResult>(result);
}
private static IConfiguration Configuration(Dictionary<string, string?>? overrides = null)
{
var values = new Dictionary<string, string?>
{
["Stripe:SecretKey"] = "sk_test_fake",
["Stripe:PricePremium"] = "price_fake",
["Stripe:WebhookSecret"] = "whsec_fake",
["App:PublicBaseUrl"] = "https://example.test",
};
if (overrides is not null)
foreach (var (key, value) in overrides) values[key] = value;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private static Mock<RoleManager<IdentityRole>> CreateRoleManager()
{
var store = new Mock<IRoleStore<IdentityRole>>();
return new Mock<RoleManager<IdentityRole>>(
store.Object,
Array.Empty<IRoleValidator<IdentityRole>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
NullLogger<RoleManager<IdentityRole>>.Instance);
}
private static BillingController Controller(
IConfiguration configuration,
Mock<UserManager<ApplicationUser>> users,
Mock<RoleManager<IdentityRole>> roles,
Mock<IStripeBillingGateway> gateway)
=> new(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance, stripe: gateway.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
};
private static void Authenticate(BillingController controller, string userId)
=> controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "local"));
private static void SetWebhookRequest(BillingController controller)
{
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
controller.Request.Headers["Stripe-Signature"] = "signed";
}
private static Subscription Subscription(string status) => new()
{
Id = "sub_lifecycle",
CustomerId = "cus_lifecycle",
Status = status,
Metadata = new Dictionary<string, string> { ["jobtracker_user_id"] = "user-1" },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Price = new Price { Id = "price_fake" } },
},
},
};
}
+10 -10
View File
@@ -18,19 +18,22 @@ public sealed class BillingController : ControllerBase
private readonly RoleManager<IdentityRole> _roles;
private readonly ILogger<BillingController> _logger;
private readonly ExternalOrigin _externalOrigin;
private readonly IStripeBillingGateway _stripe;
public BillingController(
IConfiguration configuration,
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
ILogger<BillingController> logger,
ExternalOrigin? externalOrigin = null)
ExternalOrigin? externalOrigin = null,
IStripeBillingGateway? stripe = null)
{
_configuration = configuration;
_users = users;
_roles = roles;
_logger = logger;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
_stripe = stripe ?? new StripeBillingGateway();
}
public sealed record BillingRedirectDto(string Url);
@@ -86,8 +89,7 @@ public sealed class BillingController : ControllerBase
try
{
var session = await new Stripe.Checkout.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
var session = await _stripe.CreateCheckoutAsync(secretKey, options, cancellationToken);
if (string.IsNullOrWhiteSpace(session.Url))
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
return Ok(new BillingRedirectDto(session.Url));
@@ -113,12 +115,11 @@ public sealed class BillingController : ControllerBase
try
{
var session = await new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
var session = await _stripe.CreatePortalAsync(secretKey, new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = $"{publicBaseUrl}/settings",
}, cancellationToken: cancellationToken);
}, cancellationToken);
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
@@ -143,7 +144,7 @@ public sealed class BillingController : ControllerBase
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
stripeEvent = _stripe.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
}
catch (StripeException ex)
{
@@ -164,8 +165,7 @@ public sealed class BillingController : ControllerBase
{
// Stripe does not guarantee webhook delivery order. Re-read the subscription so a late
// event cannot restore access after a newer cancellation or payment failure.
subscription = await new SubscriptionService(new StripeClient(secretKey))
.GetAsync(eventSubscription.Id, cancellationToken: cancellationToken);
subscription = await _stripe.GetSubscriptionAsync(secretKey, eventSubscription.Id, cancellationToken);
}
catch (StripeException ex)
{
@@ -229,6 +229,6 @@ public sealed class BillingController : ControllerBase
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
publicBaseUrl = _externalOrigin.BaseUrl;
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0;
return secretKey.Length > 0 && premiumPrice.StartsWith("price_", StringComparison.Ordinal) && webhookSecret.Length > 0;
}
}
+1
View File
@@ -54,6 +54,7 @@ builder.Services.AddScoped<IAiSidecarCachePurger, AiSidecarCachePurger>();
builder.Services.AddScoped<AccountDeletionService>();
builder.Services.AddScoped<AiOperationAdmission>();
builder.Services.AddScoped<AiUsageMeter>();
builder.Services.AddSingleton<IStripeBillingGateway, StripeBillingGateway>();
builder.Services.AddScoped<StrategySnapshotService>();
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
@@ -0,0 +1,35 @@
using Stripe;
namespace JobTrackerApi.Services;
public interface IStripeBillingGateway
{
Task<Stripe.Checkout.Session> CreateCheckoutAsync(string secretKey, Stripe.Checkout.SessionCreateOptions options, CancellationToken cancellationToken);
Task<Stripe.BillingPortal.Session> CreatePortalAsync(string secretKey, Stripe.BillingPortal.SessionCreateOptions options, CancellationToken cancellationToken);
Event ConstructEvent(string json, string signature, string webhookSecret);
Task<Subscription> GetSubscriptionAsync(string secretKey, string subscriptionId, CancellationToken cancellationToken);
}
public sealed class StripeBillingGateway : IStripeBillingGateway
{
public Task<Stripe.Checkout.Session> CreateCheckoutAsync(
string secretKey,
Stripe.Checkout.SessionCreateOptions options,
CancellationToken cancellationToken)
=> new Stripe.Checkout.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
public Task<Stripe.BillingPortal.Session> CreatePortalAsync(
string secretKey,
Stripe.BillingPortal.SessionCreateOptions options,
CancellationToken cancellationToken)
=> new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
public Event ConstructEvent(string json, string signature, string webhookSecret)
=> EventUtility.ConstructEvent(json, signature, webhookSecret);
public Task<Subscription> GetSubscriptionAsync(string secretKey, string subscriptionId, CancellationToken cancellationToken)
=> new SubscriptionService(new StripeClient(secretKey))
.GetAsync(subscriptionId, cancellationToken: cancellationToken);
}
+1
View File
@@ -216,3 +216,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed |
| V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed |
| V-184 | Shared synchronous AI provider decorator, durable/workspace suppression scopes, quota exception handler and full backend | Repository root | Make numeric Free/Pro AI limits universal without double-counting already-reserved work | PASS — focused shared-provider/accounting suite 25/25 and backend 674/674; success finalizes measured characters, Free/exhausted requests stop before provider I/O, workspace/operation scopes create no second row, and quota failures return stable 429 details | Fake in-process provider and SQLite only; no model, Stripe, MariaDB or production call | POL-001 repository accounting gap closed; Stripe lifecycle and production smoke remain |
| V-185 | Stripe gateway seam, mocked checkout/webhook lifecycle, entitlement tests and full backend | Repository root | Prove checkout identity and downgrade safety without using external Stripe | PASS — entitlement/billing 33/33 and backend 677/677; configured `price_` and stable user metadata reach Checkout, active grants Pro, `past_due` revokes it, canceled replay remains revoked without duplicate role mutation, non-AI profile data survives, and `prod_` in the price setting fails closed | In-process fake only; no Stripe network, customer, secret mutation, MariaDB or production call | Local POL-001 Stripe lifecycle gap closed; configured Stripe account journey remains blocked |
@@ -44,7 +44,8 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
- AI Workspace UI test: Free locked state, disabled generation and upgrade link.
- `AiUsageMeterTests`, operation integration, account export/deletion and SQLite compatibility tests cover idempotent reservation, limits, owner isolation, history-independent totals, Strategy finalization, CV conservative reservation and lifecycle handling.
- `MeteredSummarizerServiceTests` prove synchronous success finalization, pre-provider quota rejection, Free-user rejection, workspace/operation double-count suppression and stable HTTP 429 problem details.
- Full backend after universal provider admission: 674/674.
- `BillingControllerTests` use an in-process Stripe gateway fake to prove checkout price/user metadata, signed active → expired → canceled/replayed role transitions, non-AI data preservation and fail-closed rejection of a `prod_` product identifier in the price setting.
- Full entitlement/billing slice: 33/33; full backend: 677/677.
- Full backend: 568/568.
- Full frontend: 47/47 suites, 157/157 tests.
- Production frontend build: pass.
@@ -53,7 +54,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
## Limitations and remaining checks
- Browser localhost access is denied by the available browser policy, so 375/768/1440, keyboard, themes and actual navigation to the upgrade action are not claimed.
- Stripe webhook transitions were code-inspected and existing status tests cover active/trialing vs expired states, but no real or mocked end-to-end checkout/webhook cycle ran in this package.
- The repository Stripe lifecycle is covered with a fake gateway and no network call. Actual Stripe Checkout, portal configuration, signature delivery and production role mapping still require the authorized external account.
- MariaDB and production were not changed or tested.
- PRODUCT-001 removed landing-page prices, the third “Bring your own key” tier, Free AI allowance and “Unlimited AI” claims. Public capability copy now comes from one two-plan catalogue; commercial terms remain in configured Stripe Checkout.
- The durable ledger spans AI Workspace, Strategy Snapshot, CV processing and all user-scoped calls through `ISummarizerService`. Failed or empty provider attempts retain their conservative reservation because they may still have consumed provider capacity; successful generations replace it with measured input/output. Health probes and extraction-only calls are not user generation usage.
@@ -24,6 +24,7 @@ No backend behavior, database, dependency, billing configuration or production s
- Public catalogue, landing, reusable notice, usage card and active AI surfaces: 7 suites, 30/30 tests.
- Current server entitlement/billing-policy slice: 30/30 tests, including Free, Pro, Admin, stale-role downgrade and subscription-status behavior.
- Expanded local entitlement/billing slice: 33/33, including configured checkout metadata, active → expired → canceled/replayed webhook behavior and fail-closed Product-ID rejection.
- Full frontend: 57/57 suites, 232/232 tests.
- Optimized production frontend build/TypeScript: pass.
- Full Playwright: 8/8. The public plan page shows exactly Free/Pro, contains none of the retired claims, persists explicit Light/Dark, has no horizontal overflow at 375/768/1440, and both plan actions work from the keyboard.
@@ -34,7 +35,7 @@ No backend behavior, database, dependency, billing configuration or production s
- Production Stripe price/interval/trial text must continue to come from hosted Checkout. No commercial term is claimed until the configured product is inspected in the authorized production account.
- Exercise a configured Free checkout, successful webhook/role transition, portal, cancellation/expiry/downgrade and existing-data access in an authorized synthetic production account.
- Native screen-reader and switch-control spot checks remain external.
- Complete cross-feature AI usage accounting remains an AI rollout gate; the public site deliberately makes no universal numeric or unlimited claim.
- Cross-feature user-generation accounting is complete locally (V-184); the public site still avoids commercial or unlimited claims because production Stripe terms and model capacity remain external.
## Rollback
+2 -2
View File
@@ -396,9 +396,9 @@ This queue records the highest-value work that can proceed without production cr
- **Required production verification:** configured Stripe/role mapping only when operator activation is approved.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** Stripe/MariaDB/production verification is unavailable. Repository entitlement and universal user-generation accounting are complete.
- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181/V-184; focused shared-provider accounting 25/25; full backend 674/674; existing frontend/browser entitlement evidence.
- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181/V-184/V-185; focused entitlement/billing 33/33; full backend 677/677; existing frontend/browser entitlement evidence.
- **Commit:** none.
- **Remaining work:** mocked Stripe checkout/webhook expiry/downgrade lifecycle and production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims.
- **Remaining work:** configured Stripe Checkout/portal/webhook production smoke. Mocked checkout, active → expired → canceled/replayed role transitions and `price_` validation are complete (V-185). PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims.
### POL-002 — AI privacy, consent and external-fallback policy