Allow server admin to specify if an invited user subscribes for monetization or not (#7318)
What changed, and why it matters
This commit adds a new server-admin-only option called 'Bypass monetization.' When a BTCPay Server instance charges users for subscriptions, admins can now mark specific invited or existing users so they do not need a paid subscription to log in. The change is intentional and exposed through the admin UI; it does not appear to be a hidden backdoor. There is no direct evidence in the commit that it fixes a security bug or introduces a vulnerability, but any feature that lets an admin exempt users from billing controls should be reviewed for authorization and audit-trail completeness.
Treat this as a normal feature commit rather than a vulnerability patch. Reviewers should verify that only server admins can set BypassMonetization, that the event is logged durably, and that toggling the flag cannot be used to re-enable a previously disabled account outside of normal approval flows. No urgent security action is indicated by the diff alone.
Security signals we found
New admin-controlled bypass of a monetization/access-control check
Authorization to set the flag is implicit to existing server admin role; no additional authorization or multi-admin approval is visible in the diff
Event is published for audit purposes, but no explicit audit log or immutable record is added in this commit
UI checkbox is shown only when monetization is enabled, reducing accidental exposure
No input from unprivileged users; the flag is set only through server admin controllers
Evidence from the diff
The patch adds a BypassMonetization boolean to ApplicationUser, persists it via an EF Core migration on AspNetUsers, and surfaces it in the server admin user-create and user-edit views. The flag is read in MonetizationLoginExtension (skips subscription check on login) and MonetizationHostedService (skips migration/enrollment on registration, re-evaluates lockout when toggled). A new UserEvent.BypassMonetizationChanged event is published when an admin edits the flag. Tests cover create/edit flows and lockout behavior. The feature is gated by whether monetization is configured (MonetizationEnabled).
Changed components
BTCPayServer.Data/Data/ApplicationUser.csBTCPayServer.Data/Migrations/20260508113423_updateApplicationUserWithBypassMonetizationProperty.csBTCPayServer/Controllers/UIServerController.Users.csBTCPayServer/Plugins/Monetization/MonetizationHostedService.csBTCPayServer/Plugins/Monetization/MonetizationLoginExtension.csBTCPayServer/Views/UIServer/CreateUser.cshtmlBTCPayServer/Views/UIServer/User.cshtmlInspect captured patch +252 / −11
diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs
index d1b4285..27ca694 100644
--- a/BTCPayServer.Data/Data/ApplicationUser.cs
+++ b/BTCPayServer.Data/Data/ApplicationUser.cs
@@ -19,7 +19,7 @@ namespace BTCPayServer.Data
public List<APIKeyData> APIKeys { get; set; }
public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
-
+ public bool BypassMonetization { get; set; }
public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores { get; set; }
public List<Fido2Credential> Fido2Credentials { get; set; }
diff --git a/BTCPayServer.Data/Migrations/20260508113423_updateApplicationUserWithBypassMonetizationProperty.cs b/BTCPayServer.Data/Migrations/20260508113423_updateApplicationUserWithBypassMonetizationProperty.cs
new file mode 100644
index 0000000..ee58316
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260508113423_updateApplicationUserWithBypassMonetizationProperty.cs
@@ -0,0 +1,32 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260508113423_updateApplicationUserWithBypassMonetizationProperty")]
+ public partial class updateApplicationUserWithBypassMonetizationProperty : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<bool>(
+ name: "BypassMonetization",
+ table: "AspNetUsers",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "BypassMonetization",
+ table: "AspNetUsers");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 016e17f..2214691 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.4")
+ .HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -148,6 +148,9 @@ namespace BTCPayServer.Migrations
b.Property<string>("Blob2")
.HasColumnType("JSONB");
+ b.Property<bool>("BypassMonetization")
+ .HasColumnType("boolean");
+
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
diff --git a/BTCPayServer.Tests/MonetizationTests.cs b/BTCPayServer.Tests/MonetizationTests.cs
index 8b301b1..ce7bc92 100644
--- a/BTCPayServer.Tests/MonetizationTests.cs
+++ b/BTCPayServer.Tests/MonetizationTests.cs
@@ -9,6 +9,7 @@ using BTCPayServer.Views.Server;
using Microsoft.Playwright;
using Xunit;
using Xunit.Abstractions;
+using static BTCPayServer.Tests.SubscriptionTests;
using static Microsoft.Playwright.Assertions;
namespace BTCPayServer.Tests;
@@ -224,6 +225,79 @@ public class MonetizationTests(ITestOutputHelper helper) : UnitTestBase(helper)
await offeringPMO.AssertHasNotSubscriber("normal-guest@gmail.com");
}
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanBypassMonetizationForUsers()
+ {
+ await using var s = CreatePlaywrightTester(newDb: true);
+ await s.StartAsync();
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await GoToMonetization(s);
+ await s.ClickPagePrimary();
+ await s.ConfirmModal();
+ await s.FindAlertMessage(partialText: "Monetization activated");
+
+ // Case 1: Create user with bypass — should not be enrolled and can log in freely
+ await CreateUserAsAdmin(s, "skip-monetization@gmail.com", byPassMonetization: true);
+ await AssertSubscribed(s, "skip-monetization@gmail.com", false);
+ await CanLog(s, "skip-monetization@gmail.com");
+
+ // Case 2: Create user without bypass — should be enrolled
+ var ev = await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () =>
+ {
+ await CreateUserAsAdmin(s, "enrolled-invited@gmail.com", byPassMonetization: false);
+ });
+ Assert.Equal("enrolled-invited@gmail.com", ev.Subscriber.Customer.Email.Get());
+ await AssertSubscribed(s, "enrolled-invited@gmail.com", true);
+ await CanLog(s, "enrolled-invited@gmail.com");
+
+ // Case 3: Edit user — add bypass to already enrolled user
+ await SetBypassMonetization(s, "enrolled-invited@gmail.com", true);
+ await CanLog(s, "enrolled-invited@gmail.com");
+
+ // Case 4: Edit user — remove bypass from user with no subscriber
+ var ev2 = await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () =>
+ {
+ await SetBypassMonetization(s, "skip-monetization@gmail.com", false);
+ });
+ Assert.Equal("skip-monetization@gmail.com", ev2.Subscriber.Customer.Email.Get());
+ await AssertSubscribed(s, "skip-monetization@gmail.com", true);
+
+ // Case 5: Edit user — remove bypass from user who has a subscriber record
+ await GoToMonetization(s);
+ var offeringPMO = await GoToOffering(s);
+ await offeringPMO.GoToSubscribers();
+ await offeringPMO.ToggleTestSubscriber("enrolled-invited@gmail.com");
+ await s.FindAlertMessage(partialText: "is now test");
+ await using (var portal = await offeringPMO.GoToPortal("enrolled-invited@gmail.com"))
+ {
+ await portal.GoToNextPhase();
+ }
+ await s.Server.WaitForEvent<MonetizationHostedService.MonetizationLockoutUpdated>(async () =>
+ {
+ await SetBypassMonetization(s, "enrolled-invited@gmail.com", false);
+ });
+ await using (await s.SwitchPage())
+ {
+ await s.GoToUrl("/");
+ await s.LogIn("enrolled-invited@gmail.com");
+ Assert.Contains("subscriber-portal", s.Page.Url);
+ }
+ }
+
+ private async Task SetBypassMonetization(PlaywrightTester s, string email, bool bypass)
+ {
+ await s.GoToServer(ServerNavPages.Users);
+ var users = new PMO.UsersPMO(s);
+ await users.EditUser(email);
+ var bypassCheckbox = s.Page.Locator("#BypassMonetization");
+ await bypassCheckbox.SetCheckedAsync(bypass);
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage(partialText: "User successfully updated");
+ }
+
+
private async Task<SubscriptionTests.OfferingPMO> GoToOffering(PlaywrightTester s)
{
await s.Page.ClickAsync("#go-to-offering");
@@ -280,6 +354,20 @@ public class MonetizationTests(ITestOutputHelper helper) : UnitTestBase(helper)
Password = tester.Password
});
}
+ private async Task CreateUserAsAdmin(PlaywrightTester s, string email, bool byPassMonetization = true)
+ {
+ await s.GoToUrl("/server/users/new");
+ await s.Page.FillAsync("#Email", email);
+ await s.Page.FillAsync("#Password", s.Password);
+ await s.Page.FillAsync("#ConfirmPassword", s.Password);
+ var emailConfirmed = s.Page.Locator("#EmailConfirmed");
+ if (await emailConfirmed.IsVisibleAsync())
+ await emailConfirmed.CheckAsync();
+ var byPassMonetizationCheckbox = s.Page.Locator("#BypassMonetization");
+ if (await byPassMonetizationCheckbox.IsVisibleAsync())
+ await byPassMonetizationCheckbox.SetCheckedAsync(byPassMonetization);
+ await s.ClickPagePrimary();
+ }
private static async Task GoToMonetization(PlaywrightTester s)
{
diff --git a/BTCPayServer.Tests/PMO/UsersPMO.cs b/BTCPayServer.Tests/PMO/UsersPMO.cs
index d95a18d..af13b94 100644
--- a/BTCPayServer.Tests/PMO/UsersPMO.cs
+++ b/BTCPayServer.Tests/PMO/UsersPMO.cs
@@ -11,5 +11,10 @@ public class UsersPMO(PlaywrightTester s)
await s.FindAlertMessage();
}
+ public async Task EditUser(string email)
+ {
+ await s.Page.ClickAsync($"{Row(email)} .user-edit");
+ }
+
private static string Row(string email) => $"tr[data-email=\"{email}\"]";
}
diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs
index 92b08f3..72bbc42 100644
--- a/BTCPayServer/Controllers/UIServerController.Users.cs
+++ b/BTCPayServer/Controllers/UIServerController.Users.cs
@@ -8,10 +8,12 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models.ServerViewModels;
+using BTCPayServer.Plugins.Monetization;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Internal;
namespace BTCPayServer.Controllers
{
@@ -95,6 +97,8 @@ namespace BTCPayServer.Controllers
Id = user.Id,
Email = user.Email,
Name = blob?.Name,
+ BypassMonetization = user.BypassMonetization,
+ MonetizationEnabled = _monetizationSettings.Settings.IsSetup(),
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _callbackGenerator.ForInvitation(user.Id, blob.InvitationToken),
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
@@ -173,9 +177,18 @@ namespace BTCPayServer.Controllers
adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
}
+ var bypassMonetizationChanged = user.BypassMonetization != viewModel.BypassMonetization;
+ if (bypassMonetizationChanged)
+ {
+ user.BypassMonetization = viewModel.BypassMonetization;
+ propertiesChanged = true;
+ }
+
if (propertiesChanged is true)
{
propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true };
+ if (propertiesChanged is true && bypassMonetizationChanged)
+ _eventAggregator.Publish(new UserEvent.BypassMonetizationChanged(user, viewModel.BypassMonetization, Request.GetRequestBaseUrl()));
}
if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue)
@@ -223,9 +236,12 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> CreateUser()
{
await PrepareCreateUserViewData();
+ var monetizationEnabled = _monetizationSettings.Settings.IsSetup();
var vm = new RegisterFromAdminViewModel
{
- SendInvitationEmail = ViewData["CanSendEmail"] is true
+ SendInvitationEmail = ViewData["CanSendEmail"] is true,
+ BypassMonetization = !monetizationEnabled,
+ MonetizationEnabled = monetizationEnabled
};
return View(vm);
}
@@ -234,12 +250,14 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
await PrepareCreateUserViewData();
+ model.MonetizationEnabled = _monetizationSettings.Settings.IsSetup();
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
{
var user = new ApplicationUser
{
+ BypassMonetization = model.BypassMonetization,
UserName = model.Email,
Email = model.Email,
EmailConfirmed = model.EmailConfirmed,
@@ -467,5 +485,9 @@ namespace BTCPayServer.Controllers
[Display(Name = "Send invitation email")]
public bool SendInvitationEmail { get; set; } = true;
+
+ [Display(Name = "Bypass monetization for this user")]
+ public bool BypassMonetization { get; set; }
+ public bool MonetizationEnabled { get; set; }
}
}
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index 84cd9ca..831f176 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -19,10 +19,11 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models.StoreViewModels;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Apps;
using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Plugins.Monetization;
using BTCPayServer.Plugins.Translations;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Services;
using BTCPayServer.Storage.Services.Providers;
@@ -47,6 +48,7 @@ namespace BTCPayServer.Controllers
AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public partial class UIServerController : Controller
{
+ private readonly ISettingsAccessor<MonetizationSettings> _monetizationSettings;
private readonly UserManager<ApplicationUser> _UserManager;
private readonly UserService _userService;
readonly SettingsRepository _SettingsRepository;
@@ -100,7 +102,8 @@ namespace BTCPayServer.Controllers
IStringLocalizer stringLocalizer,
ViewLocalizer viewLocalizer,
BTCPayServerEnvironment environment,
- LanguagePackUpdateService languagePackUpdateService
+ LanguagePackUpdateService languagePackUpdateService,
+ ISettingsAccessor<MonetizationSettings> monetizationSettings
)
{
_policiesSettings = policiesSettings;
@@ -126,6 +129,7 @@ namespace BTCPayServer.Controllers
ApplicationLifetime = applicationLifetime;
Html = html;
_transactionLinkProviders = transactionLinkProviders;
+ _monetizationSettings = monetizationSettings;
_localizer = localizer;
Environment = environment;
StringLocalizer = stringLocalizer;
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index cdf4d7e..4576119 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -7,11 +7,12 @@ using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
+using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Plugins.Monetization;
using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
-using BTCPayServer.Plugins.Emails.Services;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
diff --git a/BTCPayServer/Events/UserEvent.cs b/BTCPayServer/Events/UserEvent.cs
index b0333b2..e431063 100644
--- a/BTCPayServer/Events/UserEvent.cs
+++ b/BTCPayServer/Events/UserEvent.cs
@@ -56,6 +56,11 @@ public class UserEvent(ApplicationUser user)
return $"{base.ToString()} has been updated";
}
}
+ public class BypassMonetizationChanged(ApplicationUser user, bool bypass, RequestBaseUrl requestBaseUrl) : UserEvent(user)
+ {
+ public bool Bypass { get; } = bypass;
+ public RequestBaseUrl RequestBaseUrl { get; } = requestBaseUrl;
+ }
public class Approved(ApplicationUser user, string loginLink) : UserEvent(user)
{
public string LoginLink { get; set; } = loginLink;
diff --git a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
index 9bf6cf6..6f8b4e7 100644
--- a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
+++ b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
@@ -41,6 +41,8 @@ namespace BTCPayServer.Models.ServerViewModels
[Display(Name = "Created")]
public DateTimeOffset? Created { get; set; }
+ public bool BypassMonetization { get; set; }
+ public bool MonetizationEnabled { get; set; }
public IEnumerable<string> Roles { get; set; }
public IEnumerable<UserStore> Stores { get; set; }
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs b/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs
index dbaeb15..4453247 100644
--- a/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs
+++ b/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs
@@ -3,6 +3,7 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
@@ -40,6 +41,7 @@ public class MonetizationHostedService(
this.Subscribe<SubscriptionEvent.SubscriberDisabled>();
this.Subscribe<SubscriptionEvent.PlanUpdated>();
this.Subscribe<SubscriptionEvent.PlanStarted>();
+ this.SubscribeAny<UserEvent.BypassMonetizationChanged>();
this.SubscribeAny<UserEvent.Registered>();
this.SubscribeAny<UserEvent.Deleted>();
}
@@ -97,12 +99,66 @@ public class MonetizationHostedService(
await UpdateUserLockoutStatus(ctx, pu.Plan);
}
+
+ if (evt is UserEvent.BypassMonetizationChanged changed && monetizationSettingsAccessor.Settings is
+ {
+ OfferingId: { } byPassOfferingId,
+ DefaultPlanId: { } byPassDefaultPlanId
+ })
+ {
+ if (changed.Bypass)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var userSub = await ctx.Subscribers.GetBySelector(byPassOfferingId, CustomerSelector.ByIdentity(SubscriberDataExtensions.IdentityType, changed.User.Id));
+ if (userSub is not null)
+ {
+ await userService.SetDisabled(changed.User.Id, false);
+ disabledUsers.Remove(changed.User.Id);
+ EventAggregator.Publish(new MonetizationLockoutUpdated([(changed.User.Id, false)]));
+ }
+ }
+ else
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var userSub = await ctx.Subscribers.GetBySelector(byPassOfferingId, CustomerSelector.ByIdentity(SubscriberDataExtensions.IdentityType, changed.User.Id));
+ if (userSub is not null)
+ {
+ var shouldBeLocked = !userSub.IsActive || userSub.Phase == SubscriberData.PhaseTypes.Expired;
+ if (shouldBeLocked)
+ {
+ await userService.SetDisabled(changed.User.Id, true);
+ disabledUsers.Add(changed.User.Id);
+ EventAggregator.Publish(new MonetizationLockoutUpdated([(changed.User.Id, true)]));
+ }
+ }
+ else
+ {
+ var inserted = await MigrateUsers(byPassOfferingId, byPassDefaultPlanId, OneUserQuery, parameters =>
+ {
+ parameters.Add("userId", changed.User.Id);
+ parameters.Add("email", changed.User.Email);
+ parameters.Add("customerId", CustomerData.GenerateId());
+ });
+ if (inserted.Length == 1)
+ {
+ var s = await ctx.Subscribers.GetByCustomerId(inserted[0].CustomerId, byPassOfferingId);
+ if (s is not null)
+ EventAggregator.Publish(new SubscriptionEvent.NewSubscriber(s, changed.RequestBaseUrl));
+ }
+ }
+ }
+ }
+
+
if (evt is UserEvent.Registered reg && monetizationSettingsAccessor.Settings is
{
OfferingId: { } offeringId,
DefaultPlanId: { } defaultPlanId
})
{
+ if (reg.User.BypassMonetization)
+ return;
+
if (await userService.IsAdminUser(reg.User))
return;
await using var ctx = dbContextFactory.CreateContext();
@@ -163,7 +219,7 @@ public class MonetizationHostedService(
=> monetizationSettingsAccessor.Settings.OfferingId == se.Subscriber.OfferingId;
- private const string NonAdminUserQuery = """
+ private const string AllUsersQuery = """
WITH subs AS (
SELECT s.id, ci.value user_id
FROM subs_subscribers s
@@ -176,6 +232,7 @@ public class MonetizationHostedService(
LEFT JOIN "AspNetUserRoles" ur ON u."Id" = ur."UserId"
LEFT JOIN "AspNetRoles" r ON r."Name"=@adminRole AND ur."RoleId" = r."Id"
WHERE r."Id" IS NULL
+ AND u."BypassMonetization" = false
),
users_to_migrate AS (
SELECT u.user_id, u.email, NULL as customer_id
@@ -191,7 +248,7 @@ public class MonetizationHostedService(
)
""";
- public async Task<(string CustomerId, string UserId)[]> MigrateUsers(string? offeringId, string? planId, string usersQuery = NonAdminUserQuery, Action<DynamicParameters>? addParameters = null)
+ public async Task<(string CustomerId, string UserId)[]> MigrateUsers(string? offeringId, string? planId, string usersQuery = AllUsersQuery, Action<DynamicParameters>? addParameters = null)
{
if (offeringId is null || planId is null)
return Array.Empty<(string CustomerId, string UserId)>();
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs b/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs
index 28655bd..f6c2b0b 100644
--- a/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs
+++ b/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs
@@ -15,6 +15,9 @@ public class MonetizationLoginExtension(
{
public override async Task Check(UserService.CanLoginContext context)
{
+ if (context.User.BypassMonetization)
+ return;
+
if (settings.Settings is { OfferingId: { } offeringId })
{
await using var ctx = dbContextFactory.CreateContext();
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs b/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs
index 275c0a1..3629297 100644
--- a/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs
+++ b/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs
@@ -4,6 +4,5 @@ public class MonetizationSettings
{
public string OfferingId { get; set; }
public string DefaultPlanId { get; set; }
-
public bool IsSetup() => OfferingId is not null && DefaultPlanId is not null;
}
diff --git a/BTCPayServer/Views/UIServer/CreateUser.cshtml b/BTCPayServer/Views/UIServer/CreateUser.cshtml
index 0c6f6fa..0598be2 100644
--- a/BTCPayServer/Views/UIServer/CreateUser.cshtml
+++ b/BTCPayServer/Views/UIServer/CreateUser.cshtml
@@ -55,7 +55,17 @@
<label asp-for="EmailConfirmed" class="form-check-label"></label>
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div>
- }
+ }
+ @if (Model.MonetizationEnabled)
+ {
+ <div class="d-flex my-3">
+ <input asp-for="BypassMonetization" type="checkbox" class="btcpay-toggle me-3" />
+ <div>
+ <label asp-for="BypassMonetization" class="form-check-label"></label>
+ <span asp-validation-for="BypassMonetization" class="text-danger"></span>
+ </div>
+ </div>
+ }
<div class="d-flex my-3">
<input asp-for="SendInvitationEmail" type="checkbox" class="btcpay-toggle me-3" disabled="@(canSendEmail ? null : "disabled")" />
<div>
diff --git a/BTCPayServer/Views/UIServer/User.cshtml b/BTCPayServer/Views/UIServer/User.cshtml
index 53da403..2c21ca8 100644
--- a/BTCPayServer/Views/UIServer/User.cshtml
+++ b/BTCPayServer/Views/UIServer/User.cshtml
@@ -73,6 +73,16 @@
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label" text-translate="true">User is admin</label>
</div>
+ @if (Model.MonetizationEnabled)
+ {
+ <div class="form-group">
+ <div class="form-check">
+ <input asp-for="BypassMonetization" class="form-check-input" />
+ <label asp-for="BypassMonetization" class="form-check-label">Bypass monetization requirement</label>
+ </div>
+ <small class="text-muted">When enabled, this user will not be required to have an active subscription.</small>
+ </div>
+ }
@if (Model.Approved.HasValue)
{
<div class="form-check my-3">
Why this scored 21/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.