How to downgrade an expired trial to a free plan in C# with Subscrio
A calibration laboratory uses GaugeBench to generate certificates during a fourteen-day trial. Afterward, it should still be able to look up stored certificates, but it should no longer generate new ones. An expired agreement and a read-only replacement are two separate pieces of that behavior.
In this guide we will downgrade an expired trial to a free plan in C#. We will configure the destination plan, run the required lifecycle processor, and inspect the resulting access.
The application must run the transition processor to assign the free plan. Between expiration and that transition, the expired trial grants neither feature, so read-only access resumes when the replacement is created. Schedule the processor to match the access promise you make to customers.
Define the customer promise
| State | Generate a certificate | Read stored certificates |
|---|---|---|
| Trial | Yes | Yes |
| Read-only replacement | No | Yes |
Run the example
The complete GaugeBench sample contains the database setup, imports, and assertions used below. Use Windows with the .NET SDK and SQL Server Express LocalDB installed. Start the default instance if it is not already running. The sample connects with Windows integrated authentication, creates its own disposable database, and removes that database when it finishes.
git clone https://github.com/subscrio/samples.git
cd samples/examples/trial-to-free-subscriptions-csharp
sqllocaldb start MSSQLLocalDB
dotnet restore --locked-mode
dotnet run --no-restore
The snippets below belong to that single program and run in order. Check compares a returned value with the expected value, prints it, and fails the run if they differ. The displayed output was captured from the sample.
Initialize Subscrio
The database helper supplies the LocalDB connection string. Install the schema before creating catalog records.
using var app = new Subscrio.Core.Subscrio(
new SubscrioConfig
{
Database = new DatabaseConfig
{
ConnectionString = database.ConnectionString,
DatabaseType = DatabaseType.SqlServer
}
}
);
await app.InstallSchemaAsync();
Create the product
GaugeBench’s plans belong to one product. The runner creates a new LocalDB database for each run, so these readable keys do not collide with earlier examples.
await app.Products.CreateProductAsync(new("gaugebench", "GaugeBench"));
Define the feature
The generate-certificates toggle controls creation of a new calibration certificate. Default it to false so the end of the trial removes that capability unless another plan supplies it.
await app.Features.CreateFeatureAsync(
new("generate-certificates", "generate-certificates", "toggle", "false")
);
Associate the feature
Associate certificate generation with GaugeBench so its plans can supply the answer.
await app.Products.AssociateFeatureAsync("gaugebench", "generate-certificates");
Define archive access
Read-only access is an explicit feature of the destination plan, not an assumption about what an expired trial can still do.
await app.Features.CreateFeatureAsync(
new("read-certificates", "Read stored certificates", "toggle", "false")
);
Associate archive access
Make stored-certificate access available to the GaugeBench plans before setting their values.
await app.Products.AssociateFeatureAsync("gaugebench", "read-certificates");
Create the two plans
The trial and its read-only destination belong to the same product.
await app.Plans.CreatePlanAsync(new("gaugebench", "trial", "Certificate trial"));
await app.Plans.CreatePlanAsync(new("gaugebench", "read-only", "Read only"));
Assign the capabilities
Both offerings can read records; only the trial generates a new certificate.
await app.Plans.SetFeatureValueAsync("trial", "generate-certificates", "true");
await app.Plans.SetFeatureValueAsync("trial", "read-certificates", "true");
await app.Plans.SetFeatureValueAsync("read-only", "generate-certificates", "false");
await app.Plans.SetFeatureValueAsync("read-only", "read-certificates", "true");
Create the cycles
Create the trial’s monthly catalog option and the free plan’s ongoing option. The trial lasts fourteen days because of the subscription dates we set below, not because the catalog cycle is monthly. The expiration policy will reference the free cycle.
await app.BillingCycles.CreateBillingCycleAsync(
new("trial", "trial-monthly", "Monthly", "months", DurationValue: 1)
);
await app.BillingCycles.CreateBillingCycleAsync(
new("read-only", "read-only-ongoing", "Ongoing", "forever")
);
Choose the free plan after trial expiration
This policy belongs to the trial plan and is used by the lifecycle processor.
await app.Plans.UpdatePlanAsync(
"trial",
new UpdatePlanDto(OnExpireTransitionToBillingCycleKey: "read-only-ongoing")
);
Create the laboratory
The subscription will belong to this customer.
await app.Customers.CreateCustomerAsync(
new("north-lab", "North calibration laboratory")
);
Start the fourteen-day trial
Set both trial and expiration deadlines. Ending a trial alone can leave an active agreement instead of triggering a downgrade.
var end = DateTime.UtcNow.AddDays(14);
await app.Subscriptions.CreateSubscriptionAsync(
new(
"lab-trial",
"north-lab",
"trial-monthly",
TrialEndDate: end,
ExpirationDate: end
)
);
Check(
"During trial: generate",
await app.FeatureChecker.IsEnabledForCustomerAsync(
"north-lab",
"gaugebench",
"generate-certificates"
),
true
);
Captured output:
During trial: generate: true
Prepare an expired test agreement
The test moves its deadlines into the past instead of waiting fourteen days. This is fixture preparation, not production scheduling. The transition query uses database time.
var past = DateTime.UtcNow.AddMinutes(-1);
await app.Subscriptions.UpdateSubscriptionAsync(
"lab-trial",
new UpdateSubscriptionDto(TrialEndDate: past, ExpirationDate: past)
);
Check(
"Expired: generate",
await app.FeatureChecker.IsEnabledForCustomerAsync(
"north-lab",
"gaugebench",
"generate-certificates"
),
false
);
Captured output:
Expired: generate: false
Process the trial-to-free downgrade
The report should identify one transition and no errors. Then verify the old record and both destination capabilities.
var report = await app.Subscriptions.TransitionExpiredSubscriptionsAsync();
Check("Transitions", report.Transitioned, 1);
Check("Transition errors", report.Errors.Count, 0);
Check(
"Old agreement archived",
(await app.Subscriptions.GetSubscriptionAsync("lab-trial"))!.IsArchived,
true
);
Check(
"Read-only: generate",
await app.FeatureChecker.IsEnabledForCustomerAsync(
"north-lab",
"gaugebench",
"generate-certificates"
),
false
);
Check(
"Read-only: read",
await app.FeatureChecker.IsEnabledForCustomerAsync(
"north-lab",
"gaugebench",
"read-certificates"
),
true
);
var again = await app.Subscriptions.TransitionExpiredSubscriptionsAsync();
Check("Second run transitions", again.Transitioned, 0);
Captured output:
Transitions: 1
Transition errors: 0
Old agreement archived: true
Read-only: generate: false
Read-only: read: true
Second run transitions: 0
The replacement retains metadata but does not copy add-on attachments or feature overrides. This fixture has neither. The replacement and archive writes are not one transaction.
Read the result
Expiration first removes certificate-generation access. The processor then creates the read-only replacement and archives the old agreement. Its report records one transition; a second run records zero. Schedule this processor in your application for the downgrade to occur without a manual call. Subscrio does not start a background scheduler for you.
Inspect the transition report for errors. The two writes are not one transaction, so a failed run may need reconciliation before a retry. The repeated run here verifies the successful path.
The Subscrio subscription feature-gating guide explains the related model. Run the complete sample to reproduce these decisions.