Blog

How to downgrade an expired trial to a free plan in C# with Subscrio

A calibration dial marks a fourteen-day trial, followed by a read-only archive.

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.

Written by

Jasen Fici

Founder, Subscrio

Bootstrapped founder. Built Velaro and StatusCast. Now building Subscrio, an entitlement engine for software products.

Screenshot preview