Allow upgrade/downgrade of a subscription at period end
What changed, and why it matters
This commit adds a new feature to BTCPay Server's subscription plugin that lets users schedule a plan upgrade or downgrade to take effect at the end of their current billing period, instead of happening immediately. It also lets users cancel a scheduled change. The change is a normal feature addition and does not appear to fix or introduce a security vulnerability based on the code shown.
No immediate security action required. Treat as a routine feature commit. If reviewing further, verify that the plan-change authorization check (planChangeRecord lookup) is still enforced for AtPeriodEnd changes and that the scheduled-plan swap correctly handles proration, billing, and invoice state transitions.
Security signals we found
No input validation changes beyond existing plan-change authorization check
No authorization or access-control modifications observed
Database migration adds non-nullable timing column with default 'Immediate'
Deferred plan swap relies on existing NewPlanId/NewPlan fields already present in the data model
UI disables additional plan changes while a scheduled change is pending, which reduces race-condition risk
Evidence from the diff
The patch extends the subscription plan-change model with a new ChangeTiming enum (Immediate / AtPeriodEnd), persists it via an EF Core migration, exposes it in admin UI and API models, and implements deferred plan migration in SubscriptionHostedService. When a subscriber chooses an AtPeriodEnd change, CreatePlanMigrationCheckout stores the target plan in Subscriber.NewPlanId and returns a sentinel string ‘scheduled’. The portal shows the pending change and disables further changes until it is cancelled or applied. At period end/expiration, the hosted service swaps the current plan for the scheduled plan and emits a PlanStarted event.
Changed components
BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.csBTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.csBTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.csBTCPayServer.Data/Data/Subscriptions/PlanChangeData.csBTCPayServer.Data/Migrations/20260322130350_AddPlanChangeTiming.csBTCPayServer.Client/Models/Subscriptions/SubscriberModel.csBTCPayServer/Plugins/Subscriptions/Mapper.csBTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddPlan.cshtmlBTCPayServer/Plugins/Subscriptions/Views/UISubscriberPortal/SubscriberPortal.cshtmlInspect captured patch +164 / −39
diff --git a/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs b/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs
index 271bdd1..91528cb 100644
--- a/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs
+++ b/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
@@ -21,6 +21,11 @@ public class SubscriberModel
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? GracePeriodEnd { get; set; }
+ public OfferingPlanModel? ScheduledPlan { get; set; }
+
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? ScheduledPlanActivatesAt { get; set; }
+
public bool IsActive { get; set; }
public bool IsSuspended { get; set; }
public string SuspensionReason { get; set; }
diff --git a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
index 5fbe24a..998a49c 100644
--- a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -178,6 +178,7 @@ public static partial class ApplicationDbContextExtensions
=> sessions
.Include(s => s.Subscriber).ThenInclude(s => s.Customer).ThenInclude(s => s.CustomerIdentities)
.Include(s => s.Subscriber).ThenInclude(s => s.Credits)
+ .Include(s => s.Subscriber).ThenInclude(s => s.NewPlan)
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.PlanChanges).ThenInclude(s => s.PlanChange)
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.Offering).ThenInclude(s => s.App).ThenInclude(s => s.StoreData);
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
index 8b173d5..73576b2 100644
--- a/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
@@ -1,4 +1,4 @@
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -23,17 +23,29 @@ public class PlanChangeData
[Required]
[Column("type")]
public ChangeType Type { get; set; }
+
+ [Required]
+ [Column("timing")]
+ public ChangeTiming Timing { get; set; } = ChangeTiming.Immediate;
+
public enum ChangeType
{
Upgrade,
Downgrade
}
+ public enum ChangeTiming
+ {
+ Immediate,
+ AtPeriodEnd
+ }
+
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PlanChangeData>();
b.HasKey(x => new { x.PlanId, x.PlanChangeId });
b.Property(x => x.Type).HasConversion<string>();
+ b.Property(x => x.Timing).HasConversion<string>().HasDefaultValue(ChangeTiming.Immediate);
b.HasOne(o => o.Plan).WithMany(o => o.PlanChanges).OnDelete(DeleteBehavior.Cascade);
b.HasOne(o => o.PlanChange).WithMany().OnDelete(DeleteBehavior.Cascade);
}
diff --git a/BTCPayServer.Data/Migrations/20260322130350_AddPlanChangeTiming.cs b/BTCPayServer.Data/Migrations/20260322130350_AddPlanChangeTiming.cs
new file mode 100644
index 0000000..839b65e
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260322130350_AddPlanChangeTiming.cs
@@ -0,0 +1,33 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260322130350_AddPlanChangeTiming")]
+ /// <inheritdoc />
+ public partial class AddPlanChangeTiming : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<string>(
+ name: "timing",
+ table: "subs_plan_changes",
+ type: "text",
+ nullable: false,
+ defaultValue: "Immediate");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "timing",
+ table: "subs_plan_changes");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 86da9a5..016e17f 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -18,7 +18,7 @@ namespace BTCPayServer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -1212,6 +1212,13 @@ namespace BTCPayServer.Migrations
.HasColumnType("text")
.HasColumnName("plan_change_id");
+ b.Property<string>("Timing")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("Immediate")
+ .HasColumnName("timing");
+
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
index 7a7cebc..06f74b5 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Linq;
using System.Threading.Tasks;
@@ -505,7 +505,10 @@ public partial class UIOfferingController(
PlanName = p.Name,
SelectedType = plan?.PlanChanges
.FirstOrDefault(pc => pc.PlanChangeId == p.Id)?
- .Type.ToString() ?? "None"
+ .Type.ToString() ?? "None",
+ Timing = plan?.PlanChanges
+ .FirstOrDefault(pc => pc.PlanChangeId == p.Id)?
+ .Timing.ToString() ?? "Immediate"
})
.OrderBy(p => p.PlanName)
.ToList(),
@@ -584,6 +587,11 @@ public partial class UIOfferingController(
"Downgrade" => PlanChangeData.ChangeType.Downgrade,
_ => PlanChangeData.ChangeType.Downgrade
};
+ existing.Timing = vmPC.Timing switch
+ {
+ "AtPeriodEnd" => PlanChangeData.ChangeTiming.AtPeriodEnd,
+ _ => PlanChangeData.ChangeTiming.Immediate
+ };
}
if (planId is null)
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
index e8bac95..5410d88 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Linq;
using System.Text.Encodings.Web;
@@ -212,6 +212,11 @@ public class UISubscriberPortalController(
}
var checkoutId = await SubsService.CreatePlanMigrationCheckout(session.Id, changedPlanId, onPay, Request.GetRequestBaseUrl());
+ if (checkoutId == SubscriptionHostedService.ScheduledResult)
+ {
+ TempData.SetStatusSuccess(StringLocalizer[ "Your plan will change at the end of your current billing period."]);
+ return RedirectToSubscriberPortal(portalSessionId);
+ }
return await RedirectToPlanCheckoutPayment(checkoutId, cancellationToken);
}
else if (command == "update-auto-renewal")
@@ -219,7 +224,13 @@ public class UISubscriberPortalController(
session.Subscriber.AutoRenew = !session.Subscriber.AutoRenew;
await ctx.SaveChangesAsync(cancellationToken);
}
-
+ else if (command == "cancel-scheduled-change")
+ {
+ session.Subscriber.NewPlanId = null;
+ session.Subscriber.NewPlan = null;
+ await ctx.SaveChangesAsync(cancellationToken);
+ TempData.SetStatusSuccess(StringLocalizer["Scheduled plan change cancelled."]);
+ }
return RedirectToSubscriberPortal(portalSessionId);
}
diff --git a/BTCPayServer/Plugins/Subscriptions/Mapper.cs b/BTCPayServer/Plugins/Subscriptions/Mapper.cs
index 9bcda5e..08c2026 100644
--- a/BTCPayServer/Plugins/Subscriptions/Mapper.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Mapper.cs
@@ -75,7 +75,9 @@ public static class Mapper
AutoRenew = sub.AutoRenew,
Metadata = JObject.Parse(sub.Metadata),
ProcessingInvoiceId = sub.ProcessingInvoiceId,
- NextPlan = MapPlan(sub.NextPlan)
+ NextPlan = MapPlan(sub.NextPlan),
+ ScheduledPlan = sub.NewPlanId is not null ? MapPlan(sub.NewPlan!) : null,
+ ScheduledPlanActivatesAt = sub.NewPlanId is not null ? sub.PeriodEnd : null
};
}
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
index 1943642..31e1a7a 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -37,6 +37,7 @@ public class SubscriptionHostedService(
Logs logger) : EventHostedServiceBase(eventAggregator, logger), IPeriodicTask
{
record Poll;
+ public const string ScheduledResult = "scheduled";
public record SubscribeRequest(string CheckoutId);
@@ -270,14 +271,21 @@ public class SubscriptionHostedService(
}
}
+ if (newPhase is PhaseTypes.Expired or PhaseTypes.Grace && m is { NewPlan: not null, NewPlanId: not null } && m.NewPlanId != m.PlanId)
+ {
+ var prevPlan = m.Plan;
+ (m.PlanId, m.Plan) = (m.NewPlanId, m.NewPlan);
+ (m.NewPlanId, m.NewPlan) = (null, null);
+ subCtx.AddEvent(new SubscriptionEvent.PlanStarted(m, prevPlan)
+ {
+ PreviousPlan = prevPlan,
+ AutoRenew = false
+ });
+ }
+
if (newPhase is PhaseTypes.Expired)
{
m.PaidAmount = null;
- if (m is { NewPlan: not null, NewPlanId: not null } && m.NewPlanId != m.PlanId)
- {
- (m.PlanId, m.Plan) = (m.NewPlanId, m.NewPlan);
- (m.NewPlanId, m.NewPlan) = (null, null);
- }
}
if (prevPhase != newPhase)
@@ -651,10 +659,18 @@ public class SubscriptionHostedService(
if (plan is null)
return null;
- var isAllowedChange = portal.Subscriber.Plan.PlanChanges
- .Any(pc => pc.PlanId == portal.Subscriber.PlanId && pc.PlanChangeId == planId);
- if (!isAllowedChange)
+ var planChangeRecord = portal.Subscriber.Plan.PlanChanges
+ .FirstOrDefault(pc => pc.PlanId == portal.Subscriber.PlanId && pc.PlanChangeId == planId);
+ if (planChangeRecord == null)
return null;
+
+ if (planChangeRecord.Timing == PlanChangeData.ChangeTiming.AtPeriodEnd)
+ {
+ portal.Subscriber.NewPlanId = planId;
+ portal.Subscriber.NewPlan = plan;
+ await ctx.SaveChangesAsync();
+ return ScheduledResult;
+ }
}
var checkout = new PlanCheckoutData(portal.Subscriber, plan)
diff --git a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddEditPlanViewModel.cs b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddEditPlanViewModel.cs
index b3c9a9f..cf2cb64 100644
--- a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddEditPlanViewModel.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddEditPlanViewModel.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Data.Subscriptions;
@@ -59,6 +59,7 @@ namespace BTCPayServer.Views.UIStoreMembership
public string PlanId { get; set; }
public string PlanName { get; set; }
public string SelectedType { get; set; }
+ public string Timing { get; set; } = "Immediate";
}
public class Feature
diff --git a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddPlan.cshtml b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddPlan.cshtml
index 06f41f6..37faeac 100644
--- a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddPlan.cshtml
+++ b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/AddPlan.cshtml
@@ -1,4 +1,4 @@
-@using BTCPayServer.Plugins.Subscriptions
+@using BTCPayServer.Plugins.Subscriptions
@model AddEditPlanViewModel
@{
@@ -108,8 +108,9 @@
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
- <th>Plan</th>
- <th>Change type</th>
+ <th>Plan</th>
+ <th>Change type</th>
+ <th>When</th>
</thead>
<tbody>
@for (int i = 0; i < Model.PlanChanges.Count; i++)
@@ -125,6 +126,13 @@
<option value="Upgrade">Upgrade</option>
<option value="None">None</option>
</select></td>
+
+ <td>
+ <select class="form-select w-auto" asp-for="PlanChanges[i].Timing">
+ <option value="Immediate">Immediately</option>
+ <option value="AtPeriodEnd">At period end</option>
+ </select>
+ </td>
</tr>
}
</tbody>
diff --git a/BTCPayServer/Plugins/Subscriptions/Views/UISubscriberPortal/SubscriberPortal.cshtml b/BTCPayServer/Plugins/Subscriptions/Views/UISubscriberPortal/SubscriberPortal.cshtml
index c37cab3..1e4838d 100644
--- a/BTCPayServer/Plugins/Subscriptions/Views/UISubscriberPortal/SubscriberPortal.cshtml
+++ b/BTCPayServer/Plugins/Subscriptions/Views/UISubscriberPortal/SubscriberPortal.cshtml
@@ -1,4 +1,4 @@
-@using BTCPayServer.Plugins.Subscriptions
+@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Services
@using NBXplorer
@model SubscriberPortalViewModel
@@ -23,6 +23,7 @@
var daysRemaining = Model.Subscriber.NextPaymentDue is null ? 0 : CountDays(Model.Subscriber.NextPaymentDue.Value);
var date = Model.Subscriber.NextPaymentDue is null ? "" : Model.Subscriber.NextPaymentDue.Value.ToString("D");
+ var hasPending = Model.Subscriber.NewPlanId is not null;
var graceRemaining = Model.Subscriber.GracePeriodEnd is null ? 0 : CountDays(Model.Subscriber.GracePeriodEnd.Value);
var currency = Model.Subscriber.Plan.Currency;
SubscriberData.PhaseTypes? nextPhase = Model.Subscriber switch
@@ -481,6 +482,26 @@
<i class="fa fa-arrow-up me-2"></i>Plan Management
</div>
<div class="card-body">
+ @if (hasPending && Model.Subscriber.NewPlan is not null)
+ {
+ <div class="alert-translucent alert-info mb-3">
+ <div class="notice-content">
+ <i class="fa fa-clock-o alert-icon"></i>
+ <div class="alert-text">
+ <div class="notice-title">Plan change scheduled</div>
+ <p class="notice-subtitle">
+ Switching to <strong>@Model.Subscriber.NewPlan.Name</strong>
+ on @(Model.Subscriber.PeriodEnd?.ToString("D") ?? "period end")
+ </p>
+ </div>
+ </div>
+ <form method="post">
+ <button type="submit" name="command" value="cancel-scheduled-change" class="btn btn-sm btn-danger">
+ Cancel
+ </button>
+ </form>
+ </div>
+ }
<div class="plan-comparison">
@foreach (var batch in Model.PlanChanges.Batch(3))
{
@@ -508,25 +529,25 @@
<input type="hidden" name="changedPlanId" value="@plan.PlanId" />
@if (plan.ChangeType == PlanChangeData.ChangeType.Upgrade)
{
- <a
- href="#"
- class="btn btn-outline-primary btn-sm"
- data-bs-toggle="modal"
- data-bs-target="#changePlanModal"
- data-action="upgrade">
- Upgrade
- </a>
+ <a href="#"
+ class="btn btn-outline-primary btn-sm @(hasPending ? "disabled" : "")"
+ @(hasPending ? "" : "data-bs-toggle=modal")
+ @(hasPending ? "" : "data-bs-target=#changePlanModal")
+ data-action="upgrade"
+ title="@(hasPending ? "Cancel your scheduled change first" : "")">
+ Upgrade
+ </a>
}
else
{
- <a
- href="#"
- class="btn btn-outline-secondary btn-sm"
- data-bs-toggle="modal"
- data-bs-target="#changePlanModal"
- data-action="downgrade">
- Downgrade
- </a>
+ <a href="#"
+ class="btn btn-outline-secondary btn-sm @(hasPending ? "disabled" : "")"
+ @(hasPending ? "" : "data-bs-toggle=modal")
+ @(hasPending ? "" : "data-bs-target=#changePlanModal")
+ data-action="downgrade"
+ title="@(hasPending ? "Cancel your scheduled change first" : "")">
+ Downgrade
+ </a>
}
</div>
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.