test(billing): prove downgrade lifecycle
This commit is contained in:
+2
-2
@@ -5,10 +5,10 @@ Updated: 2026-08-15
|
|||||||
## Stripe billing
|
## Stripe billing
|
||||||
|
|
||||||
- **Blocked:** Activating roadmap item 7.5 in production.
|
- **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.
|
- **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.
|
- **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.
|
- **Runbook:** Follow `docs/operations/stripe-activation.md`, completing test mode before creating or installing live-mode values.
|
||||||
|
|
||||||
## Document parser dependency and isolation
|
## Document parser dependency and isolation
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Security.Claims;
|
||||||
using JobTrackerApi.Controllers;
|
using JobTrackerApi.Controllers;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -8,12 +11,110 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Stripe;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace JobTrackerApi.Tests;
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
public sealed class BillingControllerTests
|
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]
|
[Fact]
|
||||||
public async Task Webhook_rejects_an_invalid_Stripe_signature()
|
public async Task Webhook_rejects_an_invalid_Stripe_signature()
|
||||||
{
|
{
|
||||||
@@ -46,4 +147,64 @@ public sealed class BillingControllerTests
|
|||||||
|
|
||||||
Assert.IsType<BadRequestObjectResult>(result);
|
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 RoleManager<IdentityRole> _roles;
|
||||||
private readonly ILogger<BillingController> _logger;
|
private readonly ILogger<BillingController> _logger;
|
||||||
private readonly ExternalOrigin _externalOrigin;
|
private readonly ExternalOrigin _externalOrigin;
|
||||||
|
private readonly IStripeBillingGateway _stripe;
|
||||||
|
|
||||||
public BillingController(
|
public BillingController(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
UserManager<ApplicationUser> users,
|
UserManager<ApplicationUser> users,
|
||||||
RoleManager<IdentityRole> roles,
|
RoleManager<IdentityRole> roles,
|
||||||
ILogger<BillingController> logger,
|
ILogger<BillingController> logger,
|
||||||
ExternalOrigin? externalOrigin = null)
|
ExternalOrigin? externalOrigin = null,
|
||||||
|
IStripeBillingGateway? stripe = null)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_users = users;
|
_users = users;
|
||||||
_roles = roles;
|
_roles = roles;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
|
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
|
||||||
|
_stripe = stripe ?? new StripeBillingGateway();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record BillingRedirectDto(string Url);
|
public sealed record BillingRedirectDto(string Url);
|
||||||
@@ -86,8 +89,7 @@ public sealed class BillingController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var session = await new Stripe.Checkout.SessionService(new StripeClient(secretKey))
|
var session = await _stripe.CreateCheckoutAsync(secretKey, options, cancellationToken);
|
||||||
.CreateAsync(options, cancellationToken: cancellationToken);
|
|
||||||
if (string.IsNullOrWhiteSpace(session.Url))
|
if (string.IsNullOrWhiteSpace(session.Url))
|
||||||
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
|
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
|
||||||
return Ok(new BillingRedirectDto(session.Url));
|
return Ok(new BillingRedirectDto(session.Url));
|
||||||
@@ -113,12 +115,11 @@ public sealed class BillingController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var session = await new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
|
var session = await _stripe.CreatePortalAsync(secretKey, new Stripe.BillingPortal.SessionCreateOptions
|
||||||
.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
|
||||||
{
|
{
|
||||||
Customer = user.StripeCustomerId,
|
Customer = user.StripeCustomerId,
|
||||||
ReturnUrl = $"{publicBaseUrl}/settings",
|
ReturnUrl = $"{publicBaseUrl}/settings",
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken);
|
||||||
return Ok(new BillingRedirectDto(session.Url));
|
return Ok(new BillingRedirectDto(session.Url));
|
||||||
}
|
}
|
||||||
catch (StripeException ex)
|
catch (StripeException ex)
|
||||||
@@ -143,7 +144,7 @@ public sealed class BillingController : ControllerBase
|
|||||||
Event stripeEvent;
|
Event stripeEvent;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
|
stripeEvent = _stripe.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
|
||||||
}
|
}
|
||||||
catch (StripeException ex)
|
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
|
// 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.
|
// event cannot restore access after a newer cancellation or payment failure.
|
||||||
subscription = await new SubscriptionService(new StripeClient(secretKey))
|
subscription = await _stripe.GetSubscriptionAsync(secretKey, eventSubscription.Id, cancellationToken);
|
||||||
.GetAsync(eventSubscription.Id, cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
catch (StripeException ex)
|
catch (StripeException ex)
|
||||||
{
|
{
|
||||||
@@ -229,6 +229,6 @@ public sealed class BillingController : ControllerBase
|
|||||||
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
|
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
|
||||||
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
|
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
|
||||||
publicBaseUrl = _externalOrigin.BaseUrl;
|
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<AccountDeletionService>();
|
||||||
builder.Services.AddScoped<AiOperationAdmission>();
|
builder.Services.AddScoped<AiOperationAdmission>();
|
||||||
builder.Services.AddScoped<AiUsageMeter>();
|
builder.Services.AddScoped<AiUsageMeter>();
|
||||||
|
builder.Services.AddSingleton<IStripeBillingGateway, StripeBillingGateway>();
|
||||||
builder.Services.AddScoped<StrategySnapshotService>();
|
builder.Services.AddScoped<StrategySnapshotService>();
|
||||||
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
||||||
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
|
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-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-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-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.
|
- 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.
|
- `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.
|
- `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 backend: 568/568.
|
||||||
- Full frontend: 47/47 suites, 157/157 tests.
|
- Full frontend: 47/47 suites, 157/157 tests.
|
||||||
- Production frontend build: pass.
|
- Production frontend build: pass.
|
||||||
@@ -53,7 +54,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
|
|||||||
## Limitations and remaining checks
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Full frontend: 57/57 suites, 232/232 tests.
|
||||||
- Optimized production frontend build/TypeScript: pass.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- **Required production verification:** configured Stripe/role mapping only when operator activation is approved.
|
||||||
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
|
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
|
||||||
- **Blocker:** Stripe/MariaDB/production verification is unavailable. Repository entitlement and universal user-generation accounting are complete.
|
- **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.
|
- **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
|
### POL-002 — AI privacy, consent and external-fallback policy
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user