How to grant app access after a one-time purchase in ASP.NET Core with Subscrio
ThreadDraft is an embroidery-design application sold for a single payment. Buying it unlocks pattern creation, editing, and export together. There is no monthly plan to renew and no separate export upgrade.
In this guide we will represent a one-time app purchase in ASP.NET Core, fulfill a confirmed order once, and check the whole-app entitlement in each tool endpoint.
The example starts with a confirmed payment and implements whole-app purchase fulfillment in C#. It creates non-expiring customer access and checks that entitlement at every paid endpoint; it does not collect the payment.
Define the customer promise
| Customer | Design | Edit | Export |
|---|---|---|---|
| Has not purchased | Denied | Denied | Denied |
| Confirmed purchaser | Available | Available | Available |
| Purchaser returning later | Available | Available | Available |
There is one whole-app entitlement and no scheduled expiration. Creating a forever billing cycle does not collect the one-time payment.
Run the example
The complete ThreadDraft sample contains the setup and executable assertions used below. Use Windows with the .NET SDK and SQL Server Express LocalDB. The runner connects with Windows integrated authentication, creates an isolated database, and removes it when finished.
git clone https://github.com/subscrio/samples.git
cd samples/examples/one-time-app-purchase-aspnet-core
sqllocaldb start MSSQLLocalDB
dotnet restore --locked-mode
dotnet run --no-restore
The snippets run in order within that program. Check prints the returned value and asserts that it matches the expected result. The outputs below come from the executable sample.
Initialize Subscrio
The database helper supplies this connection-string form. Select the SQL Server provider and install the schema before creating the catalog.
Server=(localdb)\MSSQLLocalDB;Database=SubscrioBlog_<generated name>;Integrated Security=true;TrustServerCertificate=true
var clock = new DemoClock();
using var app = new Subscrio.Core.Subscrio(
new SubscrioConfig
{
Database = new DatabaseConfig
{
ConnectionString = database.ConnectionString,
DatabaseType = DatabaseType.SqlServer
},
Clock = clock
}
);
await app.InstallSchemaAsync();
The full runner supplies the imports, database helper, and cleanup. DemoClock is a sample helper implementing Subscrio’s IClock; setting its UtcNow property changes the time used by the checks below. Its injectable clock lets the boundary checks advance time without waiting. It controls Subscrio’s entitlement/accounting evaluation; it does not change the database server clock.
Create the product
ThreadDraft’s catalog groups the purchase with the capability it controls.
await app.Products.CreateProductAsync(new("threaddraft", "ThreadDraft"));
Define the entitlement
One app-access toggle controls the complete application. The false default keeps every paid tool unavailable until the customer has purchased the app.
await app.Features.CreateFeatureAsync(
new("app-access", "app-access", "toggle", "false")
);
Associate the feature
Make this feature available to ThreadDraft’s plans.
await app.Products.AssociateFeatureAsync(
"threaddraft",
"app-access",
new FeatureResolutionOptions(SubscriptionRule: "most_generous")
);
Create the plan
The Complete app plan enables every ThreadDraft tool through one entitlement. Its billing cycle will represent a purchase with no scheduled renewal.
await app.Plans.CreatePlanAsync(new("threaddraft", "standard", "Complete app"));
Set the included value
The purchase plan enables the complete application with a true value.
await app.Plans.SetFeatureValueAsync("standard", "app-access", "true");
Create the billing cycle
A forever cycle represents access with no scheduled renewal. It does not initiate a payment.
await app.BillingCycles.CreateBillingCycleAsync(
new("standard", "standard-cycle", "Ongoing", "forever")
);
Create the customer
The sample uses the key customer for the ThreadDraft purchaser. Creating this record identifies the recipient; the subscription will assign their plan.
await app.Customers.CreateCustomerAsync(new("customer", "ThreadDraft customer"));
Fulfill a one-time app purchase without duplicate access
The order below is a server-side fixture standing in for an already verified payment. Derive the agreement key from that order so redelivery addresses the same purchase. Subscrio calls the record a subscription even when its billing cycle lasts forever.
async Task Fulfill(string paidOrderKey)
{
var key = "purchase-" + paidOrderKey;
if (await app.Subscriptions.GetSubscriptionAsync(key) != null)
return;
try
{
await app.Subscriptions.CreateSubscriptionAsync(
new(key, "customer", "standard-cycle")
);
}
catch (ConflictException)
{
if (await app.Subscriptions.GetSubscriptionAsync(key) == null)
throw;
}
}
await Fulfill("order-thread-101");
await Fulfill("order-thread-101");
Check(
"Purchase replay keeps agreement",
(await app.Subscriptions.GetSubscriptionAsync("purchase-order-thread-101"))!.Key,
"purchase-order-thread-101"
);
Check(
"Whole-app agreements",
(await app.Subscriptions.GetSubscriptionsByCustomerAsync("customer")).Count,
1
);
Output:
Purchase replay keeps agreement: "purchase-order-thread-101"
Whole-app agreements: 1
A browser success redirect is not payment evidence. The sample starts after payment verification and does not collect money. A refund or revocation needs a separate policy; forever means no scheduled expiry, not irrevocable access.
Check the whole-app entitlement in each endpoint
All three endpoints use the same entitlement. The self-contained check below starts a temporary local server, sends requests for the purchaser and an unpaid customer, then shuts the server down. The URL supplies the customer key for these test requests; the entitlement check is the same in each tool.
await app.Customers.CreateCustomerAsync(
new("visitor", "Customer without a purchase")
);
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
var web = builder.Build();
foreach (var tool in new[] { "design", "edit", "export" })
{
var action = tool;
web.MapPost(
"/customers/{customer}/" + action,
async (string customer) =>
await app.FeatureChecker.GetValueForCustomerAsync<bool>(
customer,
"threaddraft",
"app-access",
false
)
? Results.Ok(new { tool = action, status = "available" })
: Results.Json(
new { error = "app_purchase_required" },
statusCode: 403
)
);
}
web.Urls.Add("http://127.0.0.1:0");
await web.StartAsync();
try
{
using var http = new HttpClient { BaseAddress = new Uri(web.Urls.Single()) };
foreach (var tool in new[] { "design", "edit", "export" })
{
using var paid = await http.PostAsync($"/customers/customer/{tool}", null);
using var unpaid = await http.PostAsync($"/customers/visitor/{tool}", null);
Check(tool + " purchaser HTTP", (int)paid.StatusCode, 200);
Check(tool + " visitor HTTP", (int)unpaid.StatusCode, 403);
}
}
finally
{
await web.StopAsync();
await web.DisposeAsync();
}
Output:
design purchaser HTTP: 200
design visitor HTTP: 403
edit purchaser HTTP: 200
edit visitor HTTP: 403
export purchaser HTTP: 200
export visitor HTTP: 403
Verify app access without a renewal
Advance the clock a year. The purchase still grants whole-app access because the agreement has no scheduled end.
clock.UtcNow = DateTime.UtcNow.AddYears(1);
Check(
"Access on a later visit",
await app.FeatureChecker.GetValueForCustomerAsync<bool>(
"customer",
"threaddraft",
"app-access",
false
),
true
);
Output:
Access on a later visit: true
Use the result in your application
A confirmed order creates one ongoing agreement. Replaying that order keeps the same agreement, all three paid tools return HTTP 200 for the purchaser, and a customer without the purchase receives HTTP 403. A later visit uses the same entitlement without a renewal event.
Run the complete ThreadDraft example to reproduce the checks. The Subscrio subscription feature gating guide explains the related model.
For a related TypeScript example, see a whole-app purchase in TypeScript. A related next step is prepaid credit packs in C#.