test(billing): prove downgrade lifecycle
This commit is contained in:
+2
-2
@@ -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" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user