diff --git a/BLOCKERS.md b/BLOCKERS.md index cf2b00d..0a84474 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -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 diff --git a/JobTrackerApi.Tests/BillingControllerTests.cs b/JobTrackerApi.Tests/BillingControllerTests.cs index b9bff79..ca6e135 100644 --- a/JobTrackerApi.Tests/BillingControllerTests.cs +++ b/JobTrackerApi.Tests/BillingControllerTests.cs @@ -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()); + var gateway = new Mock(); + Stripe.Checkout.SessionCreateOptions? captured = null; + gateway.Setup(item => item.CreateCheckoutAsync("sk_test_fake", It.IsAny(), It.IsAny())) + .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(action.Result); + Assert.Equal("https://checkout.stripe.test/session", Assert.IsType(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()); + var configuration = Configuration(new Dictionary { ["Stripe:PricePremium"] = "prod_wrong_kind" }); + var controller = Controller(configuration, users, CreateRoleManager(), new Mock()); + Authenticate(controller, user.Id); + + var status = Assert.IsType((await controller.Status(default)).Result); + Assert.False(Assert.IsType(status.Value).Enabled); + Assert.IsType((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(); + gateway.Setup(item => item.ConstructEvent(It.IsAny(), "signed", "whsec_fake")).Returns(webhookEvent); + gateway.SetupSequence(item => item.GetSubscriptionAsync("sk_test_fake", "sub_lifecycle", It.IsAny())) + .ReturnsAsync(Subscription("active")) + .ReturnsAsync(Subscription("past_due")) + .ReturnsAsync(Subscription("canceled")); + var controller = Controller(Configuration(), users, roles, gateway); + + SetWebhookRequest(controller); + Assert.IsType(await controller.Webhook(default)); + Assert.True(hasPremium); + Assert.Equal("active", user.StripeSubscriptionStatus); + + SetWebhookRequest(controller); + Assert.IsType(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(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(result); } + + private static IConfiguration Configuration(Dictionary? overrides = null) + { + var values = new Dictionary + { + ["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> CreateRoleManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + NullLogger>.Instance); + } + + private static BillingController Controller( + IConfiguration configuration, + Mock> users, + Mock> roles, + Mock gateway) + => new(configuration, users.Object, roles.Object, NullLogger.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 { ["jobtracker_user_id"] = "user-1" }, + Items = new StripeList + { + Data = new List + { + new() { Price = new Price { Id = "price_fake" } }, + }, + }, + }; } diff --git a/JobTrackerApi/Controllers/BillingController.cs b/JobTrackerApi/Controllers/BillingController.cs index 0c4058a..22c135f 100644 --- a/JobTrackerApi/Controllers/BillingController.cs +++ b/JobTrackerApi/Controllers/BillingController.cs @@ -18,19 +18,22 @@ public sealed class BillingController : ControllerBase private readonly RoleManager _roles; private readonly ILogger _logger; private readonly ExternalOrigin _externalOrigin; + private readonly IStripeBillingGateway _stripe; public BillingController( IConfiguration configuration, UserManager users, RoleManager roles, ILogger 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; } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 096a25d..0375819 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -54,6 +54,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/StripeBillingGateway.cs b/JobTrackerApi/Services/StripeBillingGateway.cs new file mode 100644 index 0000000..d3e656b --- /dev/null +++ b/JobTrackerApi/Services/StripeBillingGateway.cs @@ -0,0 +1,35 @@ +using Stripe; + +namespace JobTrackerApi.Services; + +public interface IStripeBillingGateway +{ + Task CreateCheckoutAsync(string secretKey, Stripe.Checkout.SessionCreateOptions options, CancellationToken cancellationToken); + Task CreatePortalAsync(string secretKey, Stripe.BillingPortal.SessionCreateOptions options, CancellationToken cancellationToken); + Event ConstructEvent(string json, string signature, string webhookSecret); + Task GetSubscriptionAsync(string secretKey, string subscriptionId, CancellationToken cancellationToken); +} + +public sealed class StripeBillingGateway : IStripeBillingGateway +{ + public Task CreateCheckoutAsync( + string secretKey, + Stripe.Checkout.SessionCreateOptions options, + CancellationToken cancellationToken) + => new Stripe.Checkout.SessionService(new StripeClient(secretKey)) + .CreateAsync(options, cancellationToken: cancellationToken); + + public Task 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 GetSubscriptionAsync(string secretKey, string subscriptionId, CancellationToken cancellationToken) + => new SubscriptionService(new StripeClient(secretKey)) + .GetAsync(subscriptionId, cancellationToken: cancellationToken); +} diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index f73cba6..ce2cb45 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -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 | diff --git a/docs/verification/pol-001-free-pro-entitlements.md b/docs/verification/pol-001-free-pro-entitlements.md index fb78699..ddcc7b8 100644 --- a/docs/verification/pol-001-free-pro-entitlements.md +++ b/docs/verification/pol-001-free-pro-entitlements.md @@ -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. diff --git a/docs/verification/product-001-honest-plans.md b/docs/verification/product-001-honest-plans.md index 4baf9d8..094a671 100644 --- a/docs/verification/product-001-honest-plans.md +++ b/docs/verification/product-001-honest-plans.md @@ -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 diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index e7aa4d6..7c5911f 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -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