Dloizides.Subscriptions.AspNetCore
1.2.0
dotnet add package Dloizides.Subscriptions.AspNetCore --version 1.2.0
NuGet\Install-Package Dloizides.Subscriptions.AspNetCore -Version 1.2.0
<PackageReference Include="Dloizides.Subscriptions.AspNetCore" Version="1.2.0" />
<PackageVersion Include="Dloizides.Subscriptions.AspNetCore" Version="1.2.0" />
<PackageReference Include="Dloizides.Subscriptions.AspNetCore" />
paket add Dloizides.Subscriptions.AspNetCore --version 1.2.0
#r "nuget: Dloizides.Subscriptions.AspNetCore, 1.2.0"
#:package Dloizides.Subscriptions.AspNetCore@1.2.0
#addin nuget:?package=Dloizides.Subscriptions.AspNetCore&version=1.2.0
#tool nuget:?package=Dloizides.Subscriptions.AspNetCore&version=1.2.0
Subscriptions.AspNetCore
Reusable platform-subscription billing for multi-tenant ASP.NET Core SaaS.
Pairs Stripe Subscriptions + a plan/feature catalog + a plan gate + a
webhook handler. The consuming product brings the persistence layer
(ISubscriptionStore) and the routes; everything else is wired in
~10 lines of Program.cs.
Upgrading from ≤ 1.0.5? Read CHANGELOG.md first. The gate in those versions let lapsed paid tenants through and never ran at all under FastEndpoints. 1.1.0 fixes both, so expect callers who previously got a 200 to start correctly getting a 402.
Concepts
- Plan catalog loaded from configuration. Each plan has a code (
"free","pro","business"), a rank, a Stripe Price id (free is null), a licensed module set, and an optional seat limit. - Two orthogonal entitlement axes.
PlanPolicychecks rank — an ordered ladder, how much product?ModulePolicychecks module membership — an unordered set, which products? They are separate because no amount of Payments entitles you to CRM; collapsing them forces a combinatorial plan explosion. Both require the subscription to be in good standing. TenantSubscriptionSnapshotis the immutable per-tenant record the feature gates consult.ISubscriptionStoreis the per-product persistence layer. Each product implements it against its own DbContext.ISubscriptionServiceis the high-level API the product calls from endpoints:GetForTenant,CreateCheckoutSession,CreateCustomerPortalSession.- The plan gate (
PlanPolicy+PlanGate) returns 402 Payment Required when the caller does not qualify. The 402 body carries{ error, currentPlan, currentStatus, requiredPlan, upgradePath }so the SPA can route the user to /pricing — and can tell "never subscribed" apart from "card failed". The rule, in one place (PlanGate.MeetsRequiredPlan): plan rank must meet the requirement AND a tenant on a paid plan must be Active/Trialing. A lapsed paid plan is denied; the free tier has nothing to lapse. ITenantHierarchy(optional) lets entitlement resolve up the tenant tree to the nearest paying ancestor, so a Client tenant uses the modules its Company paid for. Entitlement inherits downward; data visibility does not — the hierarchy never enters an EF query filter.- Seat limits (
ISeatCountStore+SeatGuard) are counted inside the mutation's transaction with arowversioncompare-and-swap. A gateway cannot see that this is the 11th seat. - Stripe webhook handler verifies the HMAC + upserts the snapshot
via the store on
checkout.session.completed+customer.subscription.*events.
Wiring (consumer side)
Program.cs:
builder.Services.AddPlatformSubscriptions(builder.Configuration);
builder.Services.AddScoped<ISubscriptionStore, MyEfSubscriptionStore>();
// after app.UseAuthorization():
app.MapPlatformSubscriptionsWebhook(); // POST /api/webhooks/stripe-subscriptions
appsettings.json:
{
"PlatformSubscriptions": {
"Stripe": {
"SecretKey": "sk_test_...",
"WebhookSecret": "whsec_..."
},
"Plans": {
"free": { "DisplayName": "Free", "MonthlyPriceEur": 0, "Rank": 0, "Modules": [], "SeatLimit": 1 },
"pro": { "DisplayName": "Pro", "MonthlyPriceEur": 19, "Rank": 10, "StripePriceId": "price_...", "Modules": ["payments"], "SeatLimit": 10 },
"business": { "DisplayName": "Business", "MonthlyPriceEur": 49, "Rank": 20, "StripePriceId": "price_...", "Modules": ["payments", "accounting", "crm"], "SeatLimit": 100 }
}
}
}
Gating an endpoint
AddPlatformSubscriptions arms the gate on the standard ASP.NET Core authorization
pipeline, so it runs under FastEndpoints, minimal APIs and MVC alike. Reference the
policy by name:
FastEndpoints (what every dloizides service uses):
public sealed class CreateBatch : Endpoint<BatchRequest, BatchSummary>
{
public override void Configure()
{
Post("/v1/batches");
Policies(PlanPolicy.For(IchnosPlanCodes.Growth)); // 402 below Growth, or if lapsed
}
}
Gate on a licensed module instead of (or as well as) a tier:
Policies(ModulePolicy.For(ModuleKey.Payments)); // 402 if unlicensed OR lapsed
Policies(ModulePolicy.For(ModuleKey.Crm), PlanPolicy.Pro); // needs BOTH
When both fail, the module message is reported — upgrading the tier would not grant the licence, so leading with the rank message sends the customer to buy the wrong thing.
Minimal API: app.MapPost(...).RequireAuthorization(PlanPolicy.Pro);
MVC: [Authorize(Policy = PlanPolicy.Pro)]
Policies are materialised on demand from the "plan:<code>" and "module:<key>" names, so
you never register one policy per tier or per module. Anonymous callers get 401, not 402 — "not logged in" is never
reported as "you have not paid".
⚠️ The legacy
[RequiresPlan]attribute is MVC-only. It is an MVC authorization filter, and FastEndpoints does not invoke MVC filters — an endpoint carrying only that attribute is wide open. It is kept (and fixed) for MVC hosts; usePolicies(...)everywhere else. A test in this repo boots a real FastEndpoints host and pins this behaviour so nobody re-learns it the hard way.
The endpoint sweep
Gate the gates. Enumerate every mapped route and assert each is entitlement-gated or explicitly allowlisted — a new unlisted route fails the test:
var unprotected = EntitlementSweep.FindUnprotected(
app.Services.GetRequiredService<EndpointDataSource>(),
allowlist: ["health", "metrics", "api/webhooks/*"]);
Assert.True(unprotected.Count == 0,
"Ungated routes:
" + string.Join("
", unprotected));
A role-only policy deliberately does not count as an entitlement gate. Role permission is the third, independent axis — "may this user?" never answers "are they paying?".
Seats, tiers and downgrades
// Seat limits: rowversion CAS, so the 11th seat cannot slip past a 10-seat limit.
var grant = await SeatGuard.TryConsumeSeatAsync(store, tenantId, plan.SeatLimit);
if (!grant.Granted) return Results.Conflict(grant.Message);
// Every contract MUST declare what it is billed per. No default — throws when unset.
SeatTierTable.EnsureProvisioned(tenantId, BillableUnit.CompanyStaffSeats);
// Downgrades below current usage are blocked, naming how many users to deactivate.
var verdict = DowngradeGuard.EnsureCanChangeTo(catalog, "pro", seatsInUse: 13);
// → "Cannot move to Pro: it allows 10 seat(s) and 13 are in use. Deactivate 3 user(s) first..."
SeatTierTable bands are half-open, so a 1-10 / 10-100 overlap is unrepresentable;
overlaps and gaps throw at construction rather than producing an order-dependent price.
What the consumer owns
ISubscriptionStore— read + upsert via EF Core (or any persistence). AlsoFindTenantByStripeCustomerAsyncso the webhook can route by customer id.- The Stripe Products / Prices in their Stripe dashboard. The package reads the Price ids from config.
- The Checkout success/cancel URL design (typically per-product).
- Caching the snapshot if your feature gates fire on every request (the default store is consulted live).
Free / paid tiers
The free tier has no Stripe Subscription — TenantSubscriptionSnapshot.Status
stays None. Stripe is involved only when the tenant clicks Upgrade.
Security
- Webhook signature is verified with
Stripe.EventUtility.ConstructEvent. - The package does NOT log the Stripe secret key.
- 402 responses leak only
{ currentPlan, requiredPlan, upgradePath }— no Stripe ids, no internal state.
Stripe dashboard one-time setup
- Create Products: "Pro", "Business".
- Add a recurring Price to each (€19/mo, €49/mo). Copy the Price ids.
- Configure a Webhook endpoint:
https://<your-api>/api/webhooks/stripe-subscriptions. Subscribe to:checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted. - Copy the Webhook signing secret.
- Drop the four values into the K8s Secret backing
PlatformSubscriptions:Stripe:SecretKey,PlatformSubscriptions:Stripe:WebhookSecret,PlatformSubscriptions:Plans:Pro:StripePriceId,PlatformSubscriptions:Plans:Business:StripePriceId.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Stripe.net (>= 47.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.