Dloizides.Subscriptions.AspNetCore
1.1.0
See the version list below for details.
dotnet add package Dloizides.Subscriptions.AspNetCore --version 1.1.0
NuGet\Install-Package Dloizides.Subscriptions.AspNetCore -Version 1.1.0
<PackageReference Include="Dloizides.Subscriptions.AspNetCore" Version="1.1.0" />
<PackageVersion Include="Dloizides.Subscriptions.AspNetCore" Version="1.1.0" />
<PackageReference Include="Dloizides.Subscriptions.AspNetCore" />
paket add Dloizides.Subscriptions.AspNetCore --version 1.1.0
#r "nuget: Dloizides.Subscriptions.AspNetCore, 1.1.0"
#:package Dloizides.Subscriptions.AspNetCore@1.1.0
#addin nuget:?package=Dloizides.Subscriptions.AspNetCore&version=1.1.0
#tool nuget:?package=Dloizides.Subscriptions.AspNetCore&version=1.1.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), and a feature list. 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. - 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, "Features": ["1-event"] },
"pro": { "DisplayName": "Pro", "MonthlyPriceEur": 19, "Rank": 10, "StripePriceId": "price_...", "Features": ["unlimited-events", "no-branding", "editor"] },
"business": { "DisplayName": "Business", "MonthlyPriceEur": 49, "Rank": 20, "StripePriceId": "price_...", "Features": ["unlimited-events", "no-branding", "editor", "team", "priority-support"] }
}
}
}
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
}
}
Minimal API: app.MapPost(...).RequireAuthorization(PlanPolicy.Pro);
MVC: [Authorize(Policy = PlanPolicy.Pro)]
Policies are materialised on demand from the "plan:<code>" name, so you never register
one policy per tier. 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.
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.