What changed, and why it matters
This is a large feature commit titled 'Monetization' that adds subscription and paid-access functionality to BTCPay Server. It introduces new plugins for monetization and subscriptions, changes how user login restrictions are enforced, adds a 'limited login' authentication scheme, and refactors user registration/invitation email flows. The changes touch authentication, authorization, billing, and user lifecycle logic. While the commit is framed as a feature, several areas have security implications: a new limited-login path that signs users in under a separate scheme when normal login is blocked, changes to password-reset and invitation-token handling, and new database entities for plans/subscribers/portal sessions. No explicit security bug is visible in the diff, but the breadth and sensitivity of the modified code means defects could have significant consequences.
Treat this as a high-sensitivity feature commit requiring focused security review of the new authentication/authorization paths before release. Specifically audit: (1) the LimitedLogin scheme to ensure it cannot be used to bypass normal access controls or elevate privileges; (2) the MonetizationHostedService and entitlements logic to confirm users are correctly locked out when subscriptions expire or entitlements are removed; (3) the refactored UserEvent/invitation and password-reset flows for token-reuse or account-takeover issues; (4) the new subscription checkout and portal session endpoints for injection, IDOR, or payment bypass; and (5) the BaseUrl validation to prevent open redirects or SSRF. Run integration tests covering lockout, downgrade, suspension, and re-activation scenarios.
Security signals we found
New authentication scheme 'LimitedLogin' introduced and used in login path
Login controller signs in users under LimitedLogin scheme when normal access is denied
Password reset and invitation token handling changed
User registration/invitation event creation refactored
New subscription/plan/entitlement/subscriber/portal-session data model added
CanLogin policy checks now gate registration and password-set flows
BaseUrl setting added to server branding with URL validation
SetAdditionalData marked obsolete due to race-condition risk
TextTemplate encoding hook added to all replacements
Evidence from the diff
The commit adds two new plugins (Monetization and Subscriptions), EF Core migrations for subscription data (plans, entitlements, subscribers, portal sessions), and supporting infrastructure. Key technical changes include: (1) a new AuthenticationSchemes.LimitedLogin cookie scheme used in UIAccountController when a user passes password validation but is blocked by login policy (e.g., missing subscription/entitlement), with optional redirect handling; (2) refactoring of UserEvent.Registered/Invited creation so invitations are created through the same factory and carry RequestBaseUrl; (3) changes to password reset and set-password flows that now unset invitation tokens and check CanLogin before signing in; (4) new BaseEntityData.SetAdditionalData marked obsolete with a race-condition warning, plus a JSON helper for partial updates; (5) TextTemplate now applies an Encode callback to all replacements; (6) Safe.Raw and StatusMessageModel now handle LocalizedHtmlString with HtmlEncoder.Default; (7) Branding settings now include a configurable BaseUrl with RequestBaseUrl validation; (8) registration flow now redirects to a configured RegisterPageRedirect if set. The diff is partial (truncated) so full coverage of the new controllers and services is not available.
Changed components
BTCPayServer/Controllers/UIAccountController.csBTCPayServer/Controllers/GreenField/GreenfieldUsersController.csBTCPayServer/Controllers/UIServerController.csBTCPayServer/Controllers/UIStoresController.Users.csBTCPayServer/Events/UserEvent.csBTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer/Plugins/MonetizationBTCPayServer/Plugins/SubscriptionsBTCPayServer/Plugins/EmailsBTCPayServer.Data/Data/SubscriptionsBTCPayServer.Data/Migrations/20251028061727_subs.csBTCPayServer.Abstractions/Constants/AuthenticationSchemes.csBTCPayServer.Abstractions/Services/Safe.csBTCPayServer.Common/TextTemplate.csInspect captured patch +2814 / −451
diff --git a/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs b/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
index 007acc6..39caf7b 100644
--- a/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
+++ b/BTCPayServer.Abstractions/Constants/AuthenticationSchemes.cs
@@ -3,6 +3,11 @@ namespace BTCPayServer.Abstractions.Constants
public class AuthenticationSchemes
{
public const string Cookie = "Identity.Application";
+ /// <summary>
+ /// The user could use his password; however, some policies prevented him access to BTCPay Server.
+ /// </summary>
+ public const string LimitedLogin = "LimitedLogin";
+
public const string Bitpay = "Bitpay";
public const string Greenfield = "Greenfield.APIKeys,Greenfield.Basic";
public const string GreenfieldAPIKeys = "Greenfield.APIKeys";
diff --git a/BTCPayServer.Abstractions/Models/StatusMessageModel.cs b/BTCPayServer.Abstractions/Models/StatusMessageModel.cs
index 5cfaef3..335d62e 100644
--- a/BTCPayServer.Abstractions/Models/StatusMessageModel.cs
+++ b/BTCPayServer.Abstractions/Models/StatusMessageModel.cs
@@ -1,4 +1,9 @@
using System;
+using System.IO;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Mvc.Localization;
+using Microsoft.Extensions.Localization;
+using Newtonsoft.Json;
namespace BTCPayServer.Abstractions.Models
{
@@ -8,7 +13,27 @@ namespace BTCPayServer.Abstractions.Models
{
}
public string Message { get; set; }
+
+ [JsonIgnore]
+ public LocalizedString LocalizedMessage
+ {
+ set
+ {
+ Message = value.Value;
+ }
+ }
+
public string Html { get; set; }
+ [JsonIgnore]
+ public LocalizedHtmlString LocalizedHtml
+ {
+ set
+ {
+ StringWriter w = new();
+ value.WriteTo(w, HtmlEncoder.Default);
+ Html = w.ToString();
+ }
+ }
public StatusSeverity Severity { get; set; }
public bool AllowDismiss { get; set; } = true;
diff --git a/BTCPayServer.Abstractions/Services/Safe.cs b/BTCPayServer.Abstractions/Services/Safe.cs
index 1ffd513..dafbe50 100644
--- a/BTCPayServer.Abstractions/Services/Safe.cs
+++ b/BTCPayServer.Abstractions/Services/Safe.cs
@@ -1,6 +1,9 @@
+using System.IO;
+using System.Text.Encodings.Web;
using System.Web;
using Ganss.Xss;
using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Abstractions.Services
@@ -16,15 +19,19 @@ namespace BTCPayServer.Abstractions.Services
_htmlHelper = htmlHelper;
_jsonHelper = jsonHelper;
_htmlSanitizer = htmlSanitizer;
-
-
}
public IHtmlContent Raw(string value)
{
return _htmlHelper.Raw(_htmlSanitizer.Sanitize(value));
}
-
+ public IHtmlContent Raw(LocalizedHtmlString value)
+ {
+ var sw = new StringWriter();
+ value.WriteTo(sw, HtmlEncoder.Default);
+ return Raw(sw.ToString());
+ }
+
public IHtmlContent RawEncode(string value)
{
return _htmlHelper.Raw(HttpUtility.HtmlEncode(_htmlSanitizer.Sanitize(value)));
@@ -60,7 +67,7 @@ namespace BTCPayServer.Abstractions.Services
sane.RemovingComment += (sender, e) => bHtmlModified = true;
sane.RemovingCssClass += (sender, e) => bHtmlModified = true;
sane.RemovingStyle += (sender, e) => bHtmlModified = true;
-
+
bHtmlModified = false;
var sRet = sane.Sanitize(inputHtml);
diff --git a/BTCPayServer.Common/TextTemplate.cs b/BTCPayServer.Common/TextTemplate.cs
index 11f5e33..d6c87f1 100644
--- a/BTCPayServer.Common/TextTemplate.cs
+++ b/BTCPayServer.Common/TextTemplate.cs
@@ -13,6 +13,8 @@ public class TextTemplate(string template)
public Func<string, string> NotFoundReplacement { get; set; } = path => $"[NotFound({path})]";
public Func<string, string> ParsingErrorReplacement { get; set; } = path => $"[ParsingError({path})]";
+ public Func<(string Path, string Value), string> Encode { get; set; } = v => v.Value;
+
public string Render(JObject model)
{
model = (JObject)ToLowerCase(model);
@@ -26,11 +28,11 @@ public class TextTemplate(string template)
try
{
var token = model.SelectToken(path);
- return token?.ToString() ?? NotFoundReplacement(initial);
+ return Encode((initial, token?.ToString() ?? NotFoundReplacement(initial)));
}
catch
{
- return ParsingErrorReplacement(initial);
+ return Encode((initial, ParsingErrorReplacement(initial)));
}
});
}
diff --git a/BTCPayServer.Data/ApplicationDbContextExtensions.cs b/BTCPayServer.Data/ApplicationDbContextExtensions.cs
index 6969fae..b8bb1b8 100644
--- a/BTCPayServer.Data/ApplicationDbContextExtensions.cs
+++ b/BTCPayServer.Data/ApplicationDbContextExtensions.cs
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Data.Common;
+using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
diff --git a/BTCPayServer.Data/Data/BaseEntityData.cs b/BTCPayServer.Data/Data/BaseEntityData.cs
index 805db76..a997074 100644
--- a/BTCPayServer.Data/Data/BaseEntityData.cs
+++ b/BTCPayServer.Data/Data/BaseEntityData.cs
@@ -48,6 +48,7 @@ public class BaseEntityData
public T? GetAdditionalData<T>(string key) where T: class
=> JObject.Parse(AdditionalData)[key]?.ToObject<T>(Serializer);
+ [Obsolete("Avoid using this method, if another plugin is modifying additional data at the same time, it would overwrite the changes. Only use if you can ensure that no other plugin is modifying the data at the same time.")]
public void SetAdditionalData<T>(string key, T? obj)
{
if (obj is null)
@@ -82,4 +83,10 @@ public class BaseEntityData
b.Property(x => x.AdditionalData).HasColumnName("additional_data").HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb");
}
+
+ public static string ToUpdateAdditionalDataJson<T>(string key, T obj) where T : class
+ => new JObject()
+ {
+ [key] = JObject.FromObject(obj, Serializer)
+ }.ToString();
}
diff --git a/BTCPayServer.Data/Data/EmailRuleData.cs b/BTCPayServer.Data/Data/EmailRuleData.cs
index 76ac044..d659e06 100644
--- a/BTCPayServer.Data/Data/EmailRuleData.cs
+++ b/BTCPayServer.Data/Data/EmailRuleData.cs
@@ -41,10 +41,10 @@ public class EmailRuleData : BaseEntityData
public string[] To { get; set; } = null!;
[Required]
[Column("cc")]
- public string[] CC { get; set; } = null!;
+ public string[] CC { get; set; } = [];
[Required]
[Column("bcc")]
- public string[] BCC { get; set; } = null!;
+ public string[] BCC { get; set; } = [];
[Required]
[Column("subject")]
@@ -58,6 +58,7 @@ public class EmailRuleData : BaseEntityData
public bool CustomerEmail { get; set; }
}
public BTCPayAdditionalData? GetBTCPayAdditionalData() => this.GetAdditionalData<BTCPayAdditionalData>("btcpay");
+ [Obsolete("Avoid using this method, if another plugin is modifying additional data at the same time, it would overwrite the changes. Only use if you can ensure that no other plugin is modifying the data at the same time to avoid race conditions.")]
public void SetBTCPayAdditionalData(BTCPayAdditionalData? data) => this.SetAdditionalData("btcpay", data);
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
diff --git a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
index 4a787a7..11683f8 100644
--- a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
@@ -25,10 +25,20 @@ public static partial class ApplicationDbContextExtensions
if (storeId is not null && plan?.Offering.App.StoreDataId != storeId)
return null;
if (plan is not null)
- await FetchPlanEntitlementsAsync(plans, plan);
+ await plan.EnsureEntitlementLoaded(plans);
return plan;
}
+ public static async Task<bool> HasEntitlements(this DbSet<PlanData> plans, string planId, string entitlementCustomId)
+ {
+ var connection = plans.GetDbConnection();
+ return await connection.ExecuteScalarAsync<bool>("""
+ SELECT true FROM subs_plans_entitlements pe
+ JOIN subs_entitlements e ON e.id = pe.entitlement_id
+ WHERE pe.plan_id = @planId AND e.custom_id = @entitlementCustomId
+ """, new{ planId, entitlementCustomId });
+ }
+
public static async Task FetchPlanEntitlementsAsync<T>(this DbSet<T> ctx, IEnumerable<PlanData> plans) where T : class
{
var planIds = plans.Select(p => p.Id).Distinct().ToArray();
@@ -54,8 +64,6 @@ public static partial class ApplicationDbContextExtensions
var res = result.ToDictionary(x => x.Id, x => x);
foreach (var plan in plans)
{
- if (plan.PlanEntitlements is not null)
- continue;
plan.PlanEntitlements = new();
if (res.TryGetValue(plan.Id, out var r))
{
@@ -88,7 +96,8 @@ public static partial class ApplicationDbContextExtensions
.Include(o => o.Entitlements)
.Include(o => o.Plans)
.Include(o => o.App)
- .ThenInclude(o => o.StoreData);
+ .ThenInclude(o => o.StoreData)
+ .AsSplitQuery();
var o = await offering
.Where(o => o.Id == offeringId)
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanData.cs
index a69ed1f..3c43294 100644
--- a/BTCPayServer.Data/Data/Subscriptions/PlanData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanData.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
+using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -42,7 +43,7 @@ public class PlanData : BaseEntityData
[Required]
[Column("currency")]
- public string Currency { get; set; } = string.Empty;
+ public string Currency { get; set; } = null!;
[Required]
[Column("recurring_type")]
@@ -113,12 +114,38 @@ public class PlanData : BaseEntityData
return (to, GracePeriodDays is 0 ? null : to.AddDays(GracePeriodDays));
}
- [NotMapped]
// Avoid cartesian explosion if there are lots of entitlements
- public List<PlanEntitlementData> PlanEntitlements { get; set; } = null!;
+ private List<PlanEntitlementData>? _planEntitlements;
+ [NotMapped]
+ public List<PlanEntitlementData> PlanEntitlements
+ {
+ get => _planEntitlements ?? throw EntitlementNotLoadedException();
+ set => _planEntitlements = value;
+ }
- public PlanEntitlementData? GetEntitlement(long entitmentId)
- => PlanEntitlements.FirstOrDefault(p => p.EntitlementId == entitmentId);
+ private static InvalidOperationException EntitlementNotLoadedException()
+ {
+ return new InvalidOperationException("PlanEntitlements not loaded. Use ctx.PlanEntitlements.FetchPlanEntitlementsAsync() to load it");
+ }
+ [NotMapped]
+ public bool EntitlementsLoaded => _planEntitlements is not null;
+
+ public Task EnsureEntitlementLoaded(ApplicationDbContext ctx) => EnsureEntitlementLoaded(ctx.Plans);
+ public async Task EnsureEntitlementLoaded(DbSet<PlanData> set)
+ {
+ if (!EntitlementsLoaded)
+ await set.FetchPlanEntitlementsAsync(this);
+ }
+ public Task ReloadEntitlement(ApplicationDbContext ctx) => ReloadEntitlement(ctx.Plans);
+ public Task ReloadEntitlement(DbSet<PlanData> set) =>set.FetchPlanEntitlementsAsync(this);
+
+ public void AssertEntitlementsLoaded() => _ = _planEntitlements ?? throw EntitlementNotLoadedException();
+
+ public PlanEntitlementData? GetEntitlement(long entitlementId)
+ => PlanEntitlements.FirstOrDefault(p => p.EntitlementId == entitlementId);
+ public PlanEntitlementData? GetEntitlement(string entitlementCustomId)
+ => PlanEntitlements.FirstOrDefault(p => p.Entitlement.CustomId == entitlementCustomId);
public string[] GetEntitlementIds()
=> PlanEntitlements.Select(p => p.Entitlement.CustomId).ToArray();
+
}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
index f987b03..011d4f9 100644
--- a/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
@@ -39,6 +39,7 @@ public class PortalSessionData
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("ps"));
b.HasIndex(x => x.Expiration);
+ b.Property(x => x.Expiration).HasDefaultValueSql("now() + interval '1 day'");
b.Property(x => x.BaseUrl)
.HasConversion<string>(
x => x.ToString(),
diff --git a/BTCPayServer.Data/Migrations/20251028061727_subs.cs b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
index 03b8774..46c18ff 100644
--- a/BTCPayServer.Data/Migrations/20251028061727_subs.cs
+++ b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
@@ -300,7 +300,7 @@ namespace BTCPayServer.Migrations
{
id = table.Column<string>(type: "text", nullable: false),
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
- expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
+ expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() + interval '1 day'"),
base_url = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index b7c9af1..85c6a8d 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -1377,8 +1377,10 @@ namespace BTCPayServer.Migrations
.HasColumnName("base_url");
b.Property<DateTimeOffset>("Expiration")
+ .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
- .HasColumnName("expiration");
+ .HasColumnName("expiration")
+ .HasDefaultValueSql("now() + interval '1 day'");
b.Property<long>("SubscriberId")
.HasColumnType("bigint")
diff --git a/BTCPayServer.Tests/EmailsTests.cs b/BTCPayServer.Tests/EmailsTests.cs
index 9c74439..ce0e75b 100644
--- a/BTCPayServer.Tests/EmailsTests.cs
+++ b/BTCPayServer.Tests/EmailsTests.cs
@@ -223,7 +223,7 @@ public class EmailsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.GoToStore(StoreNavPages.Emails);
await s.Page.ClickAsync("#ConfigureEmailRules");
- Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
+ await AssertNoRules(s);
Assert.Contains("You need to configure email settings before this feature works", await s.Page.ContentAsync());
await s.Page.ClickAsync(".configure-email");
@@ -307,7 +307,7 @@ public class EmailsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.ConfirmDeleteModal();
await s.FindAlertMessage();
- Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
+ await AssertNoRules(s);
await s.Page.ClickAsync("#CreateEmailRule");
@@ -345,6 +345,11 @@ public class EmailsTests(ITestOutputHelper helper) : UnitTestBase(helper)
Assert.Contains("<p>Hello, <a id=\"reset-link\" href=\"http://", message.Html);
}
+ private static async Task AssertNoRules(PlaywrightTester s)
+ {
+ await s.Page.Locator("text=There are no rules yet.").WaitForAsync();
+ }
+
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanSetupEmailServer()
@@ -396,7 +401,7 @@ public class EmailsTests(ITestOutputHelper helper) : UnitTestBase(helper)
// Store Email Rules
await s.Page.ClickAsync("#ConfigureEmailRules");
- await s.Page.Locator("text=There are no rules yet.").WaitForAsync();
+ await AssertNoRules(s);
Assert.DoesNotContain("id=\"SaveEmailRules\"", await s.Page.ContentAsync());
Assert.DoesNotContain("You need to configure email settings before this feature works", await s.Page.ContentAsync());
diff --git a/BTCPayServer.Tests/Extensions.cs b/BTCPayServer.Tests/Extensions.cs
index b0dc8ee..86c42aa 100644
--- a/BTCPayServer.Tests/Extensions.cs
+++ b/BTCPayServer.Tests/Extensions.cs
@@ -63,6 +63,7 @@ namespace BTCPayServer.Tests
public static async Task AssertNoError(this IPage page)
{
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var pageSource = await page.ContentAsync();
if (pageSource.Contains("alert-danger"))
{
diff --git a/BTCPayServer.Tests/MonetizationTests.cs b/BTCPayServer.Tests/MonetizationTests.cs
new file mode 100644
index 0000000..090040e
--- /dev/null
+++ b/BTCPayServer.Tests/MonetizationTests.cs
@@ -0,0 +1,269 @@
+#nullable enable
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Events;
+using BTCPayServer.Plugins.Monetization;
+using BTCPayServer.Services;
+using BTCPayServer.Views.Manage;
+using Xunit;
+using Xunit.Abstractions;
+using static Microsoft.Playwright.Assertions;
+
+namespace BTCPayServer.Tests;
+
+public class MonetizationTests(ITestOutputHelper helper) : UnitTestBase(helper)
+{
+ [Fact]
+ [Trait("Playwright", "Playwright")]
+ public async Task CanMonetizeServer()
+ {
+ await using var s = CreatePlaywrightTester(newDb: true);
+ await s.StartAsync();
+ await s.RegisterNewUser(true);
+ await CreateUser(s, "old-guest@gmail.com");
+
+ await s.CreateNewStore();
+ await GoToMonetization(s);
+ await s.ClickPagePrimary();
+ await s.ConfirmModal();
+
+ await s.FindAlertMessage(partialText: "Monetization activated");
+
+ // Creating a new user, should create a new subscriber
+ var ev = await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () =>
+ {
+ await CreateUser(s, "new-guest@gmail.com");
+ });
+ Assert.Equal("new-guest@gmail.com", ev.Subscriber.Customer.Email.Get());
+
+ // Old guest should still be able to log... they didn't migrated.
+ await CanLog(s, "old-guest@gmail.com");
+ await CanLog(s, "new-guest@gmail.com", "You must have a confirmed email to log in.");
+ await AssertSubscribed(s, "new-guest@gmail.com", true);
+ await AssertSubscribed(s, "old-guest@gmail.com", false);
+
+ // When migrating users, only old-guest should be migrated.
+ // Not the admin, and not the new guest, who is already migrated.
+ await ClickSetupOffering(s);
+ await s.Page.ClickAsync("#migrate-users-button");
+ await s.ConfirmModal();
+ await s.FindAlertMessage(partialText: "1 users migrated to the plan");
+ await AssertSubscribed(s, "old-guest@gmail.com", true);
+ await AssertSubscribed(s, "new-guest@gmail.com", true);
+
+ // Now, let's try to demonetize the server and remigrate users
+ await Demonetize(s);
+ await s.ClickPagePrimary();
+ await s.Page.SetCheckedAsync("#ActivateModal_MigrateExistingUsers", true);
+ await s.ConfirmModal();
+ await s.FindAlertMessage(partialText: "(2 migrated users)");
+ await AssertSubscribed(s, "old-guest@gmail.com", true);
+ await AssertSubscribed(s, "new-guest@gmail.com", true);
+
+ // Now, let's try to demonetize the server and remigrate users, on a new store!
+ await Demonetize(s);
+ await CreateUser(s, "old2-guest@gmail.com");
+ var (_, storeId) = await s.CreateNewStore();
+ await GoToMonetization(s);
+ await s.ClickPagePrimary();
+ await s.Page.SelectOptionAsync("#ActivateModal_SelectedStoreId", storeId);
+ await s.Page.SetCheckedAsync("#ActivateModal_MigrateExistingUsers", true);
+ await s.ConfirmModal();
+ // Normally, old2-guest, new-guest and old-guest should be migrated.
+ await s.FindAlertMessage(partialText: "(3 migrated users)");
+ await AssertSubscribed(s, "old2-guest@gmail.com", true);
+ await AssertSubscribed(s, "old-guest@gmail.com", true);
+ await AssertSubscribed(s, "new-guest@gmail.com", true);
+
+ // Setup the server's email
+ await s.Page.ClickAsync("text=Configure server email settings");
+ await s.Page.ClickAsync("#server-email-collapse a");
+ await new PMO.ConfigureEmailPMO(s).FillMailPit();
+ await GoToMonetization(s);
+ // Normally, the store's email should be set up, as the server email is set as fallback.
+ await Expect(s.Page.Locator(".icon-checkmark")).ToHaveCountAsync(3);
+ var offeringPMO = await GoToOffering(s);
+ await offeringPMO.AssertActiveSubscribers(3);
+
+ await using (await s.SwitchPage())
+ {
+ await s.GoToUrl("/");
+ await s.Page.ClickAsync("#Register");
+ await s.Page.FillAsync("#emailInput", "normal-guest@gmail.com");
+
+ var newEmail = await s.Server.AssertHasEmail(async () =>
+ {
+ await s.ClickPagePrimary();
+ });
+ Assert.Equal("Confirm your email address", newEmail.Subject);
+ await s.ClickOnEmailLink(newEmail);
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info, partialText: "Your email has been confirmed. Please set your password.");
+ await s.Page.FillAsync("#Password", s.Password);
+ await s.Page.FillAsync("#ConfirmPassword", s.Password);
+ await s.ClickPagePrimary();
+ }
+ await s.FastReloadAsync();
+ await offeringPMO.AssertActiveSubscribers(4);
+
+ // What happen if normal-guest reaches end of the trial period?
+ await offeringPMO.GoToSubscribers();
+ await offeringPMO.ToggleTestSubscriber("normal-guest@gmail.com");
+ await s.FindAlertMessage(partialText: "Subscriber normal-guest@gmail.com is now test");
+
+ var offeringUrl = s.Page.Url;
+ await s.GoToStore();
+ await s.AddDerivationScheme();
+ await s.GoToUrl(offeringUrl);
+
+ await using (var portal = await offeringPMO.GoToPortal("normal-guest@gmail.com"))
+ {
+ await portal.GoToNextPhase();
+ await using (await s.SwitchPage())
+ {
+ // Login should redirect to the portal
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ await portal.AssertCallToAction(SubscriptionTests.PortalPMO.CallToAction.Danger);
+ await portal.ClickCallToAction();
+ await s.PayInvoice(mine: true, clickRedirect: true);
+ await s.FindAlertMessage();
+ await portal.AssertNoCallToAction();
+
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ await s.CreateNewStore();
+ }
+ }
+
+ // Now, the admin decides to remove can-access form plan. This should cut off access
+ await offeringPMO.GoToPlans();
+ var edit = await offeringPMO.Edit("Starter Plan");
+ edit.DisableEntitlements = ["can-access"];
+
+ var lockoutUpdated = await s.Server.WaitForEvent<MonetizationHostedService.MonetizationLockoutUpdated>(async () =>
+ {
+ await edit.Save();
+ });
+ Assert.Equal(4, lockoutUpdated.Updated.Length);
+ Assert.All(lockoutUpdated.Updated, (o) => Assert.True(o.LockoutEnabled));
+
+ // Subscribers aren't suspended... they have a valid subscription, just not one allowing access.
+ await s.FastReloadAsync();
+ await offeringPMO.AssertActiveSubscribers(4);
+ await using (await s.SwitchPage())
+ {
+ // Should not be able to login anymore
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning, partialText: "Your plan does not allow you to log in.");
+ }
+
+ // Now, the admin creates a new plan and allows users to upgrade.
+ var add = await offeringPMO.AddPlan();
+ add.PlanName = "Pro Plan";
+ add.Price = "100";
+ add.EnableEntitlements = ["can-access"];
+ await add.Save();
+ edit = await offeringPMO.Edit("Starter Plan");
+ edit.PlanChanges = [SubscriptionTests.AddEditPlanPMO.PlanChangeType.Upgrade];
+ await edit.Save();
+
+ // The user should be proposed to upgrade
+ await using (await s.SwitchPage())
+ {
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ await s.ClickPagePrimary();
+ await s.Server.WaitForEvent<SubscriptionEvent.PlanStarted>(async () =>
+ {
+ await s.PayInvoice(mine: true, clickRedirect: true);
+ });
+
+ // And after payment, he should be able to log in again
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ await s.GoToProfile("ManageBilling");
+ var portal = new SubscriptionTests.PortalPMO(s, null);
+ await portal.AssertPlan("Pro Plan");
+ await portal.AssertNoCallToAction();
+ }
+
+ await offeringPMO.GoToSubscribers();
+ await s.Server.WaitForEvent<MonetizationHostedService.MonetizationLockoutUpdated>(async () =>
+ {
+ await offeringPMO.Suspend("normal-guest@gmail.com", "You are banned!");
+ });
+
+ // The user should be banned, unable to connect to the portal.
+ await using (await s.SwitchPage())
+ {
+ await s.GoToUrl("/");
+ await s.LogIn("normal-guest@gmail.com");
+ var portal = new SubscriptionTests.PortalPMO(s, null);
+ await portal.AssertCallToAction(SubscriptionTests.PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended");
+ }
+ }
+
+ private async Task<SubscriptionTests.OfferingPMO> GoToOffering(PlaywrightTester s)
+ {
+ await s.Page.ClickAsync("#go-to-offering");
+ return new SubscriptionTests.OfferingPMO(s);
+ }
+
+ private async Task AssertSubscribed(PlaywrightTester s, string email, bool isSubscribed)
+ {
+ var settings = s.Server.PayTester.GetService<SettingsRepository>();
+ var monetization = (await settings.GetSettingAsync<MonetizationSettings>() ?? new());
+ var facto = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
+ await using var ctx = facto.CreateContext();
+ var subscriber = await ctx.Subscribers.GetBySelector(monetization.OfferingId, CustomerSelector.ByEmail(email));
+ if (isSubscribed)
+ Assert.NotNull(subscriber);
+ else
+ Assert.Null(subscriber);
+ }
+
+ private static async Task Demonetize(PlaywrightTester s)
+ {
+ await ClickSetupOffering(s);
+ await s.Page.ClickAsync("#demonetize-button");
+ await s.ConfirmModal();
+ await s.FindAlertMessage(partialText: "Monetization deactivated");
+ }
+
+ private static async Task ClickSetupOffering(PlaywrightTester s)
+ {
+ await s.Page.ClickAsync("text=Set up the offering");
+ }
+
+ private async Task CanLog(PlaywrightTester s, string email, string? error = null)
+ {
+ var newPage = await s.Browser.NewPageAsync();
+ await using var c = await s.SwitchPage(newPage);
+ await s.GoToUrl("/");
+ await s.LogIn(email, s.Password);
+ if (error is not null)
+ {
+ Assert.Contains("/login", s.Page.Url);
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning, partialText: error);
+ }
+ else
+ Assert.DoesNotContain("/login", s.Page.Url);
+ }
+
+ private async Task CreateUser(PlaywrightTester tester, string email)
+ {
+ var client = await tester.AsTestAccount().CreateClient();
+ await client.CreateUser(new()
+ {
+ Email = email,
+ Password = tester.Password
+ });
+ }
+
+ private static async Task GoToMonetization(PlaywrightTester s)
+ {
+ await s.GoToServer("MonetizationPlugin");
+ }
+}
diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs
index a87f3e9..7f06636 100644
--- a/BTCPayServer.Tests/POSTests.cs
+++ b/BTCPayServer.Tests/POSTests.cs
@@ -388,7 +388,7 @@ goodies:
// Create users
var user = await s.RegisterNewUser();
var userAccount = s.AsTestAccount();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
@@ -734,7 +734,7 @@ goodies:
// Create users
var user = await s.RegisterNewUser();
var userAccount = s.AsTestAccount();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index c082c83..5e4144f 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -44,10 +44,12 @@ namespace BTCPayServer.Tests
await Server.StartAsync();
var builder = new ConfigurationBuilder();
builder.AddUserSecrets("AB0AC1DD-9D26-485B-9416-56A33F268117");
+ var conf = builder.Build();
var playwright = await Playwright.CreateAsync();
Browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
- Headless = Server.PayTester.InContainer,
+ Headless = Server.PayTester.InContainer || conf["PLAYWRIGHT_HEADLESS"] == "true",
+ ExecutablePath = conf["PLAYWRIGHT_EXECUTABLE"],
SlowMo = 0, // 50 if you want to slow down
Args = ["--disable-frame-rate-limit"] // Fix slowness on linux (https://github.com/microsoft/playwright/issues/34625#issuecomment-2822015672)
});
@@ -116,7 +118,7 @@ namespace BTCPayServer.Tests
public async Task GoToWallet(WalletId walletId = null, WalletsNavPages? navPages = null)
{
- var walletPage = GetWalletIdFromUrl();
+ var walletPage = GetWalletIdFromUrl();
// If null, we try to go to the wallet of the current page
if (walletId is null)
{
@@ -131,6 +133,7 @@ namespace BTCPayServer.Tests
{
await GoToUrl($"wallets/{walletId}");
}
+
var cryptoCode = walletId?.CryptoCode ?? "BTC";
if (navPages is null)
{
@@ -159,7 +162,8 @@ namespace BTCPayServer.Tests
return null;
}
- public async Task<ILocator> FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success, string partialText = null)
+ public async Task<ILocator> FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success,
+ string partialText = null)
{
var locator = await FindAlertMessage(new[] { severity });
if (partialText is not null)
@@ -167,6 +171,7 @@ namespace BTCPayServer.Tests
var txt = await locator.TextContentAsync();
Assert.Contains(partialText, txt);
}
+
return locator;
}
@@ -192,7 +197,7 @@ namespace BTCPayServer.Tests
public async Task GoToUrl(string uri)
{
- await Page.GotoAsync(Link(uri), new() { WaitUntil = WaitUntilState.Commit } );
+ await Page.GotoAsync(Link(uri), new() { WaitUntil = WaitUntilState.Commit });
}
public string Link(string uri)
@@ -220,7 +225,11 @@ namespace BTCPayServer.Tests
public TestAccount AsTestAccount()
{
- return new TestAccount(Server) { StoreId = StoreId, Email = CreatedUser, Password = Password, RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser }, IsAdmin = IsAdmin };
+ return new TestAccount(Server)
+ {
+ StoreId = StoreId, Email = CreatedUser, Password = Password,
+ RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser }, IsAdmin = IsAdmin
+ };
}
public async Task<(string storeName, string storeId)> CreateNewStore(bool keepId = true, string preferredExchange = "CoinGecko")
@@ -254,7 +263,8 @@ namespace BTCPayServer.Tests
return (name, storeId);
}
- public async Task<Mnemonic> GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit)
+ public async Task<Mnemonic> GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false,
+ ScriptPubKeyType format = ScriptPubKeyType.Segwit)
{
var isImport = !string.IsNullOrEmpty(seed);
await GoToWalletSettings(cryptoCode);
@@ -318,16 +328,20 @@ namespace BTCPayServer.Tests
{
await GoToUrl("/register");
}
+
public async Task GoToLogin()
{
await GoToUrl("/login");
}
+
public async Task Logout()
{
await Page.Locator("#menu-item-Account").ClickAsync();
await Page.Locator("#Nav-Logout").ClickAsync();
}
+ public Task SkipWizard() => Page.ClickAsync("#SkipWizard");
+
public async Task GoToHome()
{
var skipWizard = Page.Locator("#SkipWizard");
@@ -337,6 +351,7 @@ namespace BTCPayServer.Tests
}
else { await GoToUrl("/"); }
}
+
public async Task AddUserToStore(string storeId, string email, string role)
{
var addUser = Page.Locator("#AddUser");
@@ -344,11 +359,13 @@ namespace BTCPayServer.Tests
{
await GoToStore(storeId, StoreNavPages.Users);
}
+
await Page.FillAsync("#Email", email);
await Page.SelectOptionAsync("#Role", role);
await Page.ClickAsync("#AddUser");
await FindAlertMessage(partialText: "The user has been added successfully");
}
+
public async Task LogIn(string user, string password = "123456")
{
await Page.FillAsync("#Email", user);
@@ -356,24 +373,30 @@ namespace BTCPayServer.Tests
await Page.ClickAsync("#LoginButton");
}
- public async Task GoToProfile(ManageNavPages navPages = ManageNavPages.Index)
+ public Task GoToProfile(ManageNavPages navPages = ManageNavPages.Index)
+ => GoToProfile(navPages.ToString());
+
+ public async Task GoToProfile(string navPages)
{
await Page.ClickAsync("#menu-item-Account");
await Page.ClickAsync("#Nav-ManageAccount");
- if (navPages != ManageNavPages.Index)
+ if (navPages != nameof(ManageNavPages.Index))
{
- await Page.ClickAsync($"#menu-item-{navPages.ToString()}");
+ await Page.ClickAsync($"#menu-item-{navPages}");
}
}
- public async Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
+ public Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
+ => GoToServer(navPages.ToString());
+
+ public async Task GoToServer(string navPages)
{
await Page.ClickAsync("#menu-item-Policies");
- if (navPages == ServerNavPages.Emails)
+ if (navPages == nameof(ServerNavPages.Emails))
{
await Page.ClickAsync($"#menu-item-Server-{navPages}");
}
- else if (navPages != ServerNavPages.Policies)
+ else if (navPages != nameof(ServerNavPages.Policies))
{
await Page.ClickAsync($"#menu-item-{navPages}");
}
@@ -388,8 +411,9 @@ namespace BTCPayServer.Tests
if (link is null or "/logout")
continue;
Assert.NotNull(link);
- links.Add(link);
+ links.Add(link);
}
+
Assert.NotEmpty(links);
foreach (var link in links)
{
@@ -405,14 +429,17 @@ namespace BTCPayServer.Tests
/// <param name="cryptoCode"></param>
/// <param name="derivationScheme"></param>
public async Task AddDerivationScheme(string cryptoCode = "BTC",
- string derivationScheme = "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]")
+ string derivationScheme =
+ "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]")
{
if (cryptoCode != "BTC" && derivationScheme ==
"tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]")
{
- derivationScheme = new BitcoinExtPubKey("tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu", Network.RegTest)
- .ToNetwork(NBitcoin.Altcoins.Litecoin.Instance.Regtest)
- .ToString()! + "-[legacy]";
+ derivationScheme =
+ new BitcoinExtPubKey("tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu",
+ Network.RegTest)
+ .ToNetwork(NBitcoin.Altcoins.Litecoin.Instance.Regtest)
+ .ToString()! + "-[legacy]";
}
if (!(await Page.ContentAsync()).Contains($"Setup {cryptoCode} Wallet"))
@@ -510,8 +537,10 @@ namespace BTCPayServer.Tests
if (storeNavPage != StoreNavPages.General)
await Page.Locator($"#menu-item-{StoreNavPages.General}").ClickAsync();
}
+
await Page.Locator($"#menu-item-{storeNavPage}").ClickAsync();
}
+
public async Task ClickCancel()
{
await Page.Locator("#CancelWizard").ClickAsync();
@@ -527,13 +556,14 @@ namespace BTCPayServer.Tests
}
-
public async ValueTask DisposeAsync()
{
static async Task Try(Func<Task> action)
{
try
- { await action(); }
+ {
+ await action();
+ }
catch { }
}
@@ -576,6 +606,7 @@ namespace BTCPayServer.Tests
goto retry;
}
}
+
await Server.ExplorerNode.GenerateAsync(1);
await Page.ReloadAsync();
await Page.Locator("#CancelWizard").ClickAsync();
@@ -607,16 +638,19 @@ namespace BTCPayServer.Tests
{
await Page.FillAsync("#test-payment-amount", amount.ToString());
}
+
await Page.ClickAsync("#FakePayment");
await Page.Locator("#CheatSuccessMessage").WaitForAsync();
if (mine)
{
await MineBlockOnInvoiceCheckout();
}
+
if (amount is null)
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
else
- await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync();
+ await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]")
+ .WaitForAsync();
if (clickRedirect)
{
await Page.ClickAsync("#StoreLink");
@@ -634,7 +668,7 @@ namespace BTCPayServer.Tests
screenshotDir = Path.Combine(screenshotDir, this.Server.Scope);
Directory.CreateDirectory(screenshotDir);
var filePath = Path.Combine(screenshotDir, fileName);
- Server.TestLogs.LogInformation("Saving test screenshot to " + filePath);
+ Server.TestLogs.LogInformation("Saving test screenshot to " + Path.GetFullPath(filePath));
await Page.ScreenshotAsync(new()
{
Path = filePath,
@@ -662,6 +696,7 @@ namespace BTCPayServer.Tests
var p = await page;
return await SwitchPage(p, closeAfter);
}
+
public async Task<IAsyncDisposable> SwitchPage(IPage page, bool closeAfter = true)
{
var old = Page;
@@ -670,6 +705,12 @@ namespace BTCPayServer.Tests
return new SwitchDisposable(page, old, this, closeAfter);
}
+ public async Task<IAsyncDisposable> SwitchPage()
+ {
+ var newPage = await Browser.NewPageAsync();
+ return await SwitchPage(newPage);
+ }
+
public async Task<WalletTransactionsPMO> GoToWalletTransactions(WalletId walletId = null)
{
await GoToWallet(walletId, navPages: WalletsNavPages.Transactions);
@@ -682,6 +723,7 @@ namespace BTCPayServer.Tests
{
private IPage Page => tester.Page;
public Task SelectAll() => Page.SetCheckedAsync(".mass-action-select-all", true);
+
public async Task Select(params uint256[] txs)
{
foreach (var txId in txs)
@@ -693,7 +735,7 @@ namespace BTCPayServer.Tests
public Task BumpFeeSelected() => Page.ClickAsync("#BumpFee");
public Task BumpFee(uint256? txId = null) => Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn");
- static string TxRowSelector(uint256? txId = null) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]";
+ static string TxRowSelector(uint256? txId = null) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]";
public async Task AssertRowContains(uint256 txId, string expected)
{
@@ -702,6 +744,7 @@ namespace BTCPayServer.Tests
}
public Task AssertHasLabels(string label) => AssertHasLabels(null, label);
+
public async Task AssertHasLabels(uint256? txId, string label)
{
// This is complicated, the labels are asynchronously added.
@@ -726,6 +769,7 @@ namespace BTCPayServer.Tests
return;
}
+
tried++;
await Page.ReloadAsync();
goto retry;
@@ -781,6 +825,7 @@ namespace BTCPayServer.Tests
var expected = ("-" + Money.Coins(amount).ToString() + " " + "BTC").NormalizeWhitespaces();
Assert.Equal(expected, actual);
}
+
public async Task Broadcast() => await page.ClickAsync("#BroadcastTransaction");
}
@@ -804,6 +849,7 @@ namespace BTCPayServer.Tests
await Page.FillAsync("#ConfirmInput", "DELETE");
await Page.ClickAsync("#ConfirmContinue");
}
+
public async Task AssertPageAccess(bool shouldHaveAccess, string url)
{
await GoToUrl(url);
@@ -824,11 +870,21 @@ namespace BTCPayServer.Tests
}
else
{
- Assert.Contains("- Denied</h", content);
+ Assert.Contains("- Denied</h", content);
}
}
public Task FastReloadAsync()
=> Page.ReloadAsync(new() { WaitUntil = WaitUntilState.Commit });
+
+ public async Task ClickOnEmailLink(MailPitClient.Message email)
+ {
+ var match = Regex.Match(email.Html, "href=[\"']([^\"']+)[\"']");
+ if (!match.Success)
+ throw new InvalidOperationException("No href found in email HTML");
+ var link = match.Groups[1].Value;
+ link = System.Net.WebUtility.HtmlDecode(link);
+ await GoToUrl(link);
+ }
}
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 128a1ae..8224e82 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -18,13 +18,11 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
-using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets;
using Dapper;
-using ExchangeSharp;
using LNURL;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -53,7 +51,7 @@ namespace BTCPayServer.Tests
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser(true);
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToServer();
await s.Page.AssertNoError();
await s.ClickOnAllSectionLinks("#mainNavSettings");
@@ -90,7 +88,7 @@ namespace BTCPayServer.Tests
await s.Page.Context.Pages.First().BringToFrontAsync();
await s.GoToUrl($"/invoices/{invoiceId}/");
- Assert.Contains("aa@aa.com", await s.Page.ContentAsync());
+ await Expect(s.Page.Locator("text=aa@aa.com")).ToBeVisibleAsync();
// Payment Request
await s.Page.ClickAsync("#menu-item-PaymentRequests");
await s.ClickPagePrimary();
@@ -119,7 +117,7 @@ namespace BTCPayServer.Tests
//Custom Forms
await s.GoToStore();
await s.GoToStore(StoreNavPages.Forms);
- Assert.Contains("There are no forms yet.", await s.Page.ContentAsync());
+ await Expect(s.Page.Locator("text=There are no forms yet.")).ToBeVisibleAsync();
await s.ClickPagePrimary();
await s.Page.FillAsync("[name='Name']", "Custom Form 1");
await s.Page.ClickAsync("#ApplyEmailTemplate");
@@ -142,7 +140,7 @@ namespace BTCPayServer.Tests
await s.GoToStore();
await s.GoToStore(StoreNavPages.Forms);
await s.Page.WaitForLoadStateAsync();
- Assert.Contains("Custom Form 1", await s.Page.ContentAsync());
+ await Expect(s.Page.Locator("text=Custom Form 1")).ToBeVisibleAsync();
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" }).ClickAsync();
await s.ConfirmDeleteModal();
await s.Page.WaitForLoadStateAsync();
@@ -163,13 +161,12 @@ namespace BTCPayServer.Tests
await s.GoToHome();
await s.GoToStore();
await s.GoToStore(StoreNavPages.Forms);
- Assert.Contains("Custom Form 2", await s.Page.ContentAsync());
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Custom Form 2" }).ClickAsync();
await s.Page.Locator("[name='Name']").ClearAsync();
await s.Page.FillAsync("[name='Name']", "Custom Form 3");
await s.ClickPagePrimary();
await s.GoToStore(StoreNavPages.Forms);
- Assert.Contains("Custom Form 3", await s.Page.ContentAsync());
+ await Expect(s.Page.Locator("text=Custom Form 3")).ToBeVisibleAsync();
await s.Page.ClickAsync("#menu-item-PaymentRequests");
await s.ClickPagePrimary();
var selectOptions = await s.Page.Locator("#FormId >> option").CountAsync();
@@ -215,11 +212,11 @@ namespace BTCPayServer.Tests
await s.StartAsync();
await s.RegisterNewUser();
var user = s.AsTestAccount();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToServer(ServerNavPages.Users);
@@ -227,6 +224,7 @@ namespace BTCPayServer.Tests
await s.Page.Locator("#SearchTerm").ClearAsync();
await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email);
await s.Page.Locator("#SearchTerm").PressAsync("Enter");
+ await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var rows = s.Page.Locator("#UsersList tr.user-overview-row");
Assert.Equal(1, await rows.CountAsync());
Assert.Contains(user.RegisterDetails.Email, await rows.First.TextContentAsync());
@@ -235,12 +233,14 @@ namespace BTCPayServer.Tests
await s.Page.FillAsync("#ConfirmPassword", "Password@1!");
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "Password successfully set");
+ user.Password = "Password@1!";
var userPage = await s.Browser.NewPageAsync();
await using (await s.SwitchPage(userPage, false))
{
await s.GoToLogin();
await s.LogIn(user.Email, user.Password);
+ await s.SkipWizard();
}
// Manage user status (disable and enable)
// Disable user
@@ -257,6 +257,7 @@ namespace BTCPayServer.Tests
await using (await s.SwitchPage(userPage, false))
{
await s.Page.ReloadAsync();
+ await s.LogIn(user.Email, user.Password);
await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning, partialText: "Your user account is currently disabled");
}
@@ -368,7 +369,7 @@ namespace BTCPayServer.Tests
await s.StartAsync();
//Register & Log Out
var email = await s.RegisterNewUser();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.Page.AssertNoError();
Assert.Contains("/login", s.Page.Url);
@@ -414,7 +415,7 @@ namespace BTCPayServer.Tests
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToServer(ServerNavPages.Users);
await s.ClickPagePrimary();
@@ -1216,7 +1217,7 @@ namespace BTCPayServer.Tests
await s.RegisterNewUser(true);
var admin = s.AsTestAccount();
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToServer(ServerNavPages.Policies);
Assert.True(await s.Page.Locator("#EnableRegistration").IsCheckedAsync());
@@ -1232,8 +1233,7 @@ namespace BTCPayServer.Tests
await s.GoToRegister();
await s.RegisterNewUser();
- await s.Page.AssertNoError();
- await s.FindAlertMessage(partialText: "Account created. The new account requires approval by an admin before you can log in");
+ await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning, partialText: "Your user account requires approval by an admin before you can log in.");
Assert.Contains("/login", s.Page.Url);
var unapproved = s.AsTestAccount();
@@ -1498,7 +1498,7 @@ namespace BTCPayServer.Tests
s.Server.ActivateLightning();
await s.StartAsync();
await s.RegisterNewUser(true);
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToServer(ServerNavPages.Services);
await s.Page.AssertNoError();
s.TestLogs.LogInformation("Let's see if we can access LND's seed");
@@ -1525,7 +1525,7 @@ namespace BTCPayServer.Tests
await using var s = CreatePlaywrightTester();
await s.StartAsync();
var user = await s.RegisterNewUser(true);
- await s.GoToHome();
+ await s.SkipWizard();
await s.GoToProfile(ManageNavPages.TwoFactorAuthentication);
await s.Page.FillAsync("[name='Name']", "ln wallet");
await s.Page.SelectOptionAsync("[name='type']", $"{(int)Fido2Credential.CredentialType.LNURLAuth}");
@@ -2041,15 +2041,15 @@ namespace BTCPayServer.Tests
// Setup users
var manager = await s.RegisterNewUser();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
var employee = await s.RegisterNewUser();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
var guest = await s.RegisterNewUser();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
@@ -2528,7 +2528,7 @@ namespace BTCPayServer.Tests
// Setup users and store
var employee = await s.RegisterNewUser();
- await s.GoToHome();
+ await s.SkipWizard();
await s.Logout();
await s.GoToRegister();
var owner = await s.RegisterNewUser(true);
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
index a91c6a4..a778163 100644
--- a/BTCPayServer.Tests/SubscriptionTests.cs
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -17,6 +17,7 @@ using NBXplorer;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
+using static Microsoft.Playwright.Assertions;
namespace BTCPayServer.Tests;
@@ -261,7 +262,6 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
unused = await portal.AssertRefunded(unused);
totalRefunded += unused;
await s.Page.EvaluateAsync("window.scrollTo(0, document.body.scrollHeight)");
- await s.TakeScreenshot("upgrade2.png");
await portal.AssertCreditHistory(
[
"Upgrade to new plan 'Enterprise Plan'",
@@ -351,18 +351,18 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true);
// basic@example.com is a basic plan subscriber (without optimistic activation), so he needs to wait confirmation
- offering.GoToPlans();
+ await offering.GoToPlans();
var edit = await offering.Edit("Basic Plan");
edit.OptimisticActivation = false;
await edit.Save();
await offering.NewSubscriber("Basic Plan", "basic@example.com", false);
- offering.GoToPlans();
+ await offering.GoToPlans();
edit = await offering.Edit("Basic Plan");
edit.OptimisticActivation = true;
await edit.Save();
- // basic2@example.com is a basic plan subscriber (optimistic activation), so he is imediatly activated
+ // basic2@example.com is a basic plan subscriber (optimistic activation), so he is immediately activated
await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () => {
await offering.NewSubscriber("Basic Plan", "basic2@example.com", false);
});
@@ -505,7 +505,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
}
}
- class OfferingPMO(PlaywrightTester s)
+ public class OfferingPMO(PlaywrightTester s)
{
public Task Configure()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Configure" }).ClickAsync();
@@ -543,7 +543,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
public Task GoToSubscribers()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscribers" }).ClickAsync();
- public void GoToPlans()
+ public Task GoToPlans()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Plans" }).ClickAsync();
public Task GoToMails()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Mails" }).ClickAsync();
@@ -683,9 +683,20 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
{
Assert.Equal(expected.PaymentRemindersDays, actual.PaymentRemindersDays);
}
+
+ public async Task AssertActiveSubscribers(int subscribersCount)
+ {
+ await Expect(s.Page.Locator("#total-subscribers")).ToHaveTextAsync(subscribersCount.ToString());
+ }
+
+ public async Task ToggleTestSubscriber(string subscriber)
+ {
+ await s.Page.ClickAsync(SubscriberRowSelector(subscriber) + " .subscriber-email-col .dropdown-toggle");
+ await s.Page.ClickAsync(SubscriberRowSelector(subscriber) + " .subscriber-email-col button");
+ }
}
- class PortalPMO(PlaywrightTester s, IAsyncDisposable disposable) : IAsyncDisposable
+ public class PortalPMO(PlaywrightTester s, IAsyncDisposable? disposable) : IAsyncDisposable
{
public async Task ClickCallToAction()
=> await s.Page.ClickAsync("div.alert-translucent button");
@@ -726,7 +737,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
=> Assert.Equal(0, await s.Page.Locator($"div.alert-translucent").CountAsync());
- public ValueTask DisposeAsync() => disposable.DisposeAsync();
+ public ValueTask DisposeAsync() => disposable?.DisposeAsync() ?? ValueTask.CompletedTask;
public Task GoToNextPhase()
=> s.Page.ClickAsync("#MovePhase");
@@ -852,7 +863,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
}
}
- class AddEditPlanPMO(PlaywrightTester tester)
+ public class AddEditPlanPMO(PlaywrightTester tester)
{
public string? PlanName { get; set; }
public string? Price { get; set; }
diff --git a/BTCPayServer.Tests/UnitTestBase.cs b/BTCPayServer.Tests/UnitTestBase.cs
index efb9472..76fbdd0 100644
--- a/BTCPayServer.Tests/UnitTestBase.cs
+++ b/BTCPayServer.Tests/UnitTestBase.cs
@@ -15,9 +15,11 @@ using Xunit.Abstractions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Memory;
using NBitcoin;
+using Xunit;
namespace BTCPayServer.Tests
{
+ [Collection(nameof(NonParallelizableCollectionDefinition))]
public class UnitTestBase
{
public UnitTestBase(ITestOutputHelper helper)
@@ -57,7 +59,7 @@ namespace BTCPayServer.Tests
InitialData = new[] {
new KeyValuePair<string, string>("chains", "*"),
new KeyValuePair<string, string>("network", "regtest")
- }
+ }
})
});
var bootstrap = Startup.CreateBootstrap(conf);
diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj
index d7c4d2b..e504a43 100644
--- a/BTCPayServer/BTCPayServer.csproj
+++ b/BTCPayServer/BTCPayServer.csproj
@@ -94,7 +94,6 @@
</ItemGroup>
<ItemGroup>
- <Folder Include="Plugins\Emails\Mails\" />
<Folder Include="wwwroot\vendor\bootstrap" />
<Folder Include="wwwroot\vendor\clipboard.js\" />
<Folder Include="wwwroot\vendor\highlightjs\" />
diff --git a/BTCPayServer/Components/StoreSelector/Default.cshtml b/BTCPayServer/Components/StoreSelector/Default.cshtml
index 0de642e..8939808 100644
--- a/BTCPayServer/Components/StoreSelector/Default.cshtml
+++ b/BTCPayServer/Components/StoreSelector/Default.cshtml
@@ -51,7 +51,7 @@ else
@foreach (var option in Model.Options)
{
<li>
- <a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected && !ViewData.ContainsKey("StoreList"))" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
+ <a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item @(option.Selected && !ViewData.ContainsKey("StoreList") ? "active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
</li>
}
@if (Model.Options.Any())
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs
index 877aa5d..d6d14c6 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs
@@ -101,7 +101,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
var success = await _userService.SetDisabled(user.Id, request.Locked);
- return success ? Ok() : this.CreateAPIError("invalid-state",
+ return success is not UserService.SetDisabledResult.Error ? Ok() : this.CreateAPIError("invalid-state",
$"{(request.Locked ? "Locking" : "Unlocking")} user failed");
}
@@ -413,14 +413,7 @@ namespace BTCPayServer.Controllers.Greenfield
await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs);
}
}
- var currentUser = await _userManager.GetUserAsync(User);
- var userEvent = currentUser switch
- {
- { } invitedBy => await UserEvent.Invited.Create(user, invitedBy, _callbackGenerator, request.SendInvitationEmail is not false),
- _ => await UserEvent.Registered.Create(user, _callbackGenerator)
- };
- _eventAggregator.Publish(userEvent);
-
+ _eventAggregator.Publish(await UserEvent.Registered.Create(user, await _userManager.GetUserAsync(User), _callbackGenerator, request.SendInvitationEmail is not false));
var model = await ForAPI(user);
return CreatedAtAction(string.Empty, model);
}
diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs
index d35f06b..0548474 100644
--- a/BTCPayServer/Controllers/UIAccountController.cs
+++ b/BTCPayServer/Controllers/UIAccountController.cs
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
+using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@@ -13,6 +14,7 @@ using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Filters;
using BTCPayServer.Logging;
+using BTCPayServer.Models;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services;
using BTCPayServer.Plugins.Emails.Services;
@@ -22,11 +24,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
-using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
-using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers
@@ -85,9 +85,9 @@ namespace BTCPayServer.Controllers
[HttpGet("/login")]
[AllowAnonymous]
- public async Task<IActionResult> Login(string returnUrl = null, string email = null)
+ public async Task<IActionResult> Login(string returnUrl = null, string email = null, bool allowLimitedLogin = false)
{
- if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
+ if (User.Identity?.IsAuthenticated is true && string.IsNullOrEmpty(returnUrl))
return RedirectToLocal();
// Clear the existing external cookie to ensure a clean login process
@@ -99,7 +99,7 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
- return View(nameof(Login), new LoginViewModel { Email = email });
+ return View(nameof(Login), new LoginViewModel { Email = email, AllowLimitedLogin = allowLimitedLogin });
}
// GET is for signin via the POS backend
@@ -170,13 +170,32 @@ namespace BTCPayServer.Controllers
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
- TempData.SetStatusLoginResult(loginContext);
- return View(model);
+ if (user is null || !await userManager.CheckPasswordAsync(user, model.Password))
+ {
+ if (user is not null)
+ await userManager.AccessFailedAsync(user);
+ ModelState.AddModelError(string.Empty, errorMessage!);
+ return View(model);
+ }
+ // Only show the real reason if the user has input the right password...
+ else
+ {
+ var principal = await signInManager.CreateUserPrincipalAsync(user);
+ await HttpContext.SignInAsync(AuthenticationSchemes.LimitedLogin, principal);
+ if (model.AllowLimitedLogin && returnUrl != null)
+ return RedirectToLocal(returnUrl);
+
+ if (loginContext.FailedRedirectUrl is { } url)
+ return Redirect(url);
+ else
+ TempData.SetStatusLoginResult(loginContext);
+ return RedirectToAction(nameof(Login));
+ }
}
var fido2Devices = await fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await lnurlAuthService.HasCredentials(user.Id);
- if (!await userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
+ if (fido2Devices || lnurlAuthCredentials)
{
if (await userManager.CheckPasswordAsync(user, model.Password))
{
@@ -526,6 +545,12 @@ namespace BTCPayServer.Controllers
}
if (PoliciesSettings.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
+
+ if (!string.IsNullOrWhiteSpace(PoliciesSettings.RegisterPageRedirect))
+ {
+ return Redirect(HttpContext.Request.GetAbsoluteUri(PoliciesSettings.RegisterPageRedirect));
+ }
+
ViewData["ReturnUrl"] = returnUrl;
return View();
}
@@ -536,16 +561,13 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null, bool logon = true)
{
if (!CanLoginOrRegister())
- {
- return RedirectToAction("Register");
- }
+ return RedirectToAction(nameof(Register));
+ var r = Register(returnUrl);
+ if (r is not ViewResult)
+ return r;
- ViewData["ReturnUrl"] = returnUrl;
ViewData["Logon"] = logon.ToString(CultureInfo.InvariantCulture).ToLowerInvariant();
var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
- if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
- return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
-
if (ModelState.IsValid)
{
var anyAdmin = (await userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
@@ -573,24 +595,25 @@ namespace BTCPayServer.Controllers
RegisteredAdmin = true;
}
- eventAggregator.Publish(await UserEvent.Registered.Create(user, callbackGenerator));
+ eventAggregator.Publish(await UserEvent.Registered.Create(user, null, callbackGenerator));
RegisteredUserId = user.Id;
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Account created."].Value;
- var requiresConfirmedEmail = policies.RequiresConfirmedEmail && !user.EmailConfirmed;
- var requiresUserApproval = policies.RequiresUserApproval && !user.Approved;
- if (requiresConfirmedEmail)
- {
- TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
- }
- if (requiresUserApproval)
- {
- TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
- }
- if (requiresConfirmedEmail || requiresUserApproval)
+
+ var ctx = CreateLoginContext(user);
+ if (!await userService.CanLogin(ctx))
{
- return RedirectToAction(nameof(Login));
+ if (ctx.FailedRedirectUrl is { } url)
+ {
+ return Redirect(url);
+ }
+ else
+ {
+ TempData.SetStatusLoginResult(ctx);
+ return RedirectToAction(nameof(Login));
+ }
}
+
if (logon)
{
await signInManager.SignInAsync(user, isPersistent: false);
@@ -619,7 +642,6 @@ namespace BTCPayServer.Controllers
var user = await userManager.FindByIdAsync(userId);
await signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
- _logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login));
}
@@ -633,36 +655,36 @@ namespace BTCPayServer.Controllers
}
var user = await userManager.FindByIdAsync(userId);
if (user == null)
- {
- throw new ApplicationException($"Unable to load user with ID '{userId}'.");
- }
+ return NotFound();
var result = await userManager.ConfirmEmailAsync(user, code);
- if (result.Succeeded)
- {
- var approvalLink = callbackGenerator.ForApproval(user);
- eventAggregator.Publish(new UserEvent.ConfirmedEmail(user, approvalLink));
-
- var hasPassword = await userManager.HasPasswordAsync(user);
- if (hasPassword)
+ if (!result.Succeeded)
+ return View("Error", new ErrorViewModel()
{
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Message = StringLocalizer["Your email has been confirmed."].Value
- });
- return RedirectToAction(nameof(Login), new { email = user.Email });
- }
+ Error = result.Errors.FirstOrDefault()?.Code,
+ ErrorDescription = result.Errors.FirstOrDefault()?.Description
+ });
+
+ var approvalLink = callbackGenerator.ForApproval(user);
+ eventAggregator.Publish(new UserEvent.ConfirmedEmail(user, approvalLink));
+ var hasPassword = await userManager.HasPasswordAsync(user);
+ if (hasPassword)
+ {
TempData.SetStatusMessageModel(new StatusMessageModel
{
- Severity = StatusMessageModel.StatusSeverity.Info,
- Message = StringLocalizer["Your email has been confirmed. Please set your password."].Value
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ Message = StringLocalizer["Your email has been confirmed."].Value
});
- return await RedirectToSetPassword(user);
+ return RedirectToAction(nameof(Login), new { email = user.Email });
}
- return View("Error");
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Info,
+ Message = StringLocalizer["Your email has been confirmed. Please set your password."].Value
+ });
+ return await RedirectToSetPassword(user);
}
[HttpGet("/login/forgot-password")]
@@ -744,8 +766,11 @@ namespace BTCPayServer.Controllers
var needsInitialPassword = !await userManager.HasPasswordAsync(user);
// Let unapproved users set a password. Otherwise, don't reveal that the user does not exist.
var loginContext = CreateLoginContext(user);
- if (!await userService.CanLogin(loginContext) && !needsInitialPassword)
+ if (!await userService.CanLogin(loginContext) && !needsInitialPassword)
+ {
+ TempData.SetStatusLoginResult(loginContext);
return RedirectToAction(nameof(Login));
+ }
var result = await userManager.ResetPasswordAsync(user!, model.Code, model.Password);
if (result.Succeeded)
@@ -763,6 +788,7 @@ namespace BTCPayServer.Controllers
if (needsInitialPassword && await userService.CanLogin(loginContext))
{
var signInResult = await signInManager.PasswordSignInAsync(user.Email!, model.Password, true, true);
+ await userManager.UnsetInvitationTokenAsync(user.Id);
if (signInResult.Succeeded)
{
return RedirectToLocal(returnUrl);
@@ -785,7 +811,7 @@ namespace BTCPayServer.Controllers
return NotFound();
}
- var user = await userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
+ var user = await userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code));
if (user == null)
{
return NotFound();
@@ -813,6 +839,7 @@ namespace BTCPayServer.Controllers
Severity = StatusMessageModel.StatusSeverity.Info,
Message = StringLocalizer["Your password has been set by the user who invited you."].Value
});
+ await userManager.UnsetInvitationTokenAsync(user.Id);
return RedirectToAction(nameof(Login), new { email = user.Email });
}
diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs
index e4de5a9..92b08f3 100644
--- a/BTCPayServer/Controllers/UIServerController.Users.cs
+++ b/BTCPayServer/Controllers/UIServerController.Users.cs
@@ -261,7 +261,7 @@ namespace BTCPayServer.Controllers
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
var sendEmail = model.SendInvitationEmail && ViewData["CanSendEmail"] is true;
- var evt = await UserEvent.Invited.Create(user, currentUser, _callbackGenerator, sendEmail);
+ var evt = (UserEvent.Invited)await UserEvent.Registered.Create(user, currentUser, _callbackGenerator, sendEmail);
_eventAggregator.Publish(evt);
var info = sendEmail
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index 3d3a4e5..a0d5e88 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -9,6 +9,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
@@ -1080,6 +1081,7 @@ namespace BTCPayServer.Controllers
var vm = new BrandingViewModel
{
ServerName = server.ServerName,
+ BaseUrl = server.BaseUrl,
ContactUrl = server.ContactUrl,
CustomTheme = theme.CustomTheme,
CustomThemeExtension = theme.CustomThemeExtension,
@@ -1093,8 +1095,25 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> Branding(
BrandingViewModel vm,
[FromForm] bool RemoveLogoFile,
- [FromForm] bool RemoveCustomThemeFile)
+ [FromForm] bool RemoveCustomThemeFile,
+ [FromForm] string? command = null)
{
+ if (command is "SetBaseUrl")
+ vm.BaseUrl = HttpContext.Request.GetRequestBaseUrl().ToString();
+ if (string.IsNullOrEmpty(vm.BaseUrl))
+ {
+ vm.BaseUrl = null;
+ }
+ else
+ {
+ if (!RequestBaseUrl.TryFromUrl(vm.BaseUrl, out var baseUrl))
+ ModelState.AddModelError(nameof(vm.BaseUrl), StringLocalizer["Invalid Base URL"]);
+ vm.BaseUrl = baseUrl?.ToString();
+ vm.BaseUrl = vm.BaseUrl?.WithoutEndingSlash();
+ }
+
+ if (!ModelState.IsValid)
+ return View(vm);
var settingsChanged = false;
var server = await _SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var theme = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
@@ -1119,6 +1138,11 @@ namespace BTCPayServer.Controllers
: null;
settingsChanged = true;
}
+ if (server.BaseUrl != vm.BaseUrl)
+ {
+ server.BaseUrl = vm.BaseUrl;
+ settingsChanged = true;
+ }
if (settingsChanged)
{
diff --git a/BTCPayServer/Controllers/UIStoresController.Users.cs b/BTCPayServer/Controllers/UIStoresController.Users.cs
index 915d0e0..a9b69a4 100644
--- a/BTCPayServer/Controllers/UIStoresController.Users.cs
+++ b/BTCPayServer/Controllers/UIStoresController.Users.cs
@@ -74,7 +74,7 @@ public partial class UIStoresController
(await _userManager.CreateAsync(user)) is { Succeeded: true })
{
var invitationEmail = await _emailSenderFactory.IsComplete();
- var evt = await UserEvent.Invited.Create(user!, currentUser, _callbackGenerator, invitationEmail);
+ var evt = (UserEvent.Invited)await UserEvent.Registered.Create(user!, currentUser, _callbackGenerator, invitationEmail);
_eventAggregator.Publish(evt);
inviteInfo = invitationEmail
? StringLocalizer["An invitation email has been sent.<br/>You may alternatively share this link with them: <a class='alert-link' href='{0}'>{0}</a>", evt.InvitationLink]
diff --git a/BTCPayServer/Events/UserEvent.cs b/BTCPayServer/Events/UserEvent.cs
index 9508571..f38b6e9 100644
--- a/BTCPayServer/Events/UserEvent.cs
+++ b/BTCPayServer/Events/UserEvent.cs
@@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
+using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Http;
@@ -26,33 +28,29 @@ public class UserEvent(ApplicationUser user)
public string ConfirmLink { get; } = confirmLink;
}
- public class Registered(ApplicationUser user, string approvalLink, string confirmationEmail) : UserEvent(user)
+ public class Registered(ApplicationUser user, RequestBaseUrl requestBaseUrl, string approvalLink, string confirmationEmail) : UserEvent(user)
{
public string ApprovalLink { get; } = approvalLink;
public string ConfirmationEmailLink { get; set; } = confirmationEmail;
- public static async Task<Registered> Create(ApplicationUser user, CallbackGenerator callbackGenerator)
+ public RequestBaseUrl RequestBaseUrl { get; set; } = requestBaseUrl;
+ public static async Task<Registered> Create(ApplicationUser user, ApplicationUser? invitedBy, CallbackGenerator callbackGenerator, bool sendInvitationEmail = true)
{
var approvalLink = callbackGenerator.ForApproval(user);
var confirmationEmail = await callbackGenerator.ForEmailConfirmation(user);
- return new Registered(user, approvalLink, confirmationEmail);
+ if (invitedBy is null)
+ return new Registered(user, callbackGenerator.GetRequestBaseUrl(), approvalLink, confirmationEmail);
+ var invitationLink = await callbackGenerator.ForInvitation(user);
+ return new Invited(user, invitedBy, callbackGenerator.GetRequestBaseUrl(), invitationLink, approvalLink, confirmationEmail)
+ {
+ SendInvitationEmail = sendInvitationEmail
+ };
}
}
- public class Invited(ApplicationUser user, ApplicationUser invitedBy, string invitationLink, string approvalLink, string confirmationEmail) : Registered(user, approvalLink, confirmationEmail)
+ public class Invited(ApplicationUser user, ApplicationUser invitedBy, RequestBaseUrl requestBaseUrl, string invitationLink, string approvalLink, string confirmationEmail) : Registered(user, requestBaseUrl, approvalLink, confirmationEmail)
{
public bool SendInvitationEmail { get; set; }
public ApplicationUser InvitedByUser { get; } = invitedBy;
public string InvitationLink { get; } = invitationLink;
-
- public static async Task<Invited> Create(ApplicationUser user, ApplicationUser currentUser, CallbackGenerator callbackGenerator, bool sendEmail)
- {
- var invitationLink = await callbackGenerator.ForInvitation(user);
- var approvalLink = callbackGenerator.ForApproval(user);
- var confirmationEmail = await callbackGenerator.ForEmailConfirmation(user);
- return new Invited(user, currentUser, invitationLink, approvalLink, confirmationEmail)
- {
- SendInvitationEmail = sendEmail
- };
- }
}
public class Updated(ApplicationUser user) : UserEvent(user)
{
diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs
index 66eeb68..3e158db 100644
--- a/BTCPayServer/Extensions.cs
+++ b/BTCPayServer/Extensions.cs
@@ -15,6 +15,7 @@ using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
@@ -44,6 +45,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitcoin.Payment;
using NBitcoin.RPC;
@@ -443,6 +445,14 @@ namespace BTCPayServer
#pragma warning restore CS0618 // Type or member is obsolete
return services;
}
+ public static void AddSettingsAccessor<T>(this IServiceCollection services) where T : class, new()
+ {
+ services.TryAddSingleton<ISettingsAccessor<T>, SettingsAccessor<T>>();
+ services.AddSingleton<IHostedService>(provider => (SettingsAccessor<T>)provider.GetRequiredService<ISettingsAccessor<T>>());
+ services.AddSingleton<IStartupTask>(provider => (SettingsAccessor<T>)provider.GetRequiredService<ISettingsAccessor<T>>());
+ // Singletons shouldn't reference the settings directly, but ISettingsAccessor<T>, since singletons won't have refreshed values of the setting
+ services.AddTransient<T>(provider => provider.GetRequiredService<ISettingsAccessor<T>>().Settings);
+ }
public static IServiceCollection AddReportProvider<T>(this IServiceCollection services)
where T : ReportProvider
{
@@ -866,15 +876,28 @@ namespace BTCPayServer
{
if (!loginContext.Failures.Any())
throw new InvalidOperationException("No login failure found");
+ tempData.Remove(WellKnownTempData.SuccessMessage);
+ tempData.Remove(WellKnownTempData.ErrorMessage);
var model = new StatusMessageModel()
{
Severity = loginContext._user is null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning
};
- var failures =
- loginContext
- .Failures
- .Select(f => f.Html is null ? HtmlEncoder.Default.Encode(f.Text.Value) : f.Html.Value)
- .ToArray();
+
+ List<string> failures = new();
+ foreach (var failure in loginContext.Failures)
+ {
+ StringWriter writer = new();
+ if (failure.Html is null)
+ {
+ writer.Write(failure.Text.Value);
+ }
+ else
+ {
+ failure.Html.WriteTo(writer, HtmlEncoder.Default);
+ }
+ failures.Add(writer.ToString());
+ }
+
model.Html = string.Join("<br/>", failures);
tempData.SetStatusMessageModel(model);
}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 7abe2ed..9a5e723 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -69,10 +69,15 @@ using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.WalletFileParsing;
using BTCPayServer.Payments.LNURLPay;
using System.Collections.Generic;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Payouts;
using ExchangeSharp;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Localization;
using Microsoft.AspNetCore.Mvc.Localization;
+using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Hosting
@@ -84,6 +89,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IJsonConverterRegistration, JsonConverterRegistration>((s) => new JsonConverterRegistration(create));
return services;
}
+
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration, Logs logs)
{
services.TryAddScoped<CallbackGenerator>();
@@ -91,14 +97,15 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<IHtmlLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<LocalizerService>();
services.TryAddSingleton<ViewLocalizer>();
- services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("",""));
+ services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("", ""));
services.TryAddSingleton<DelayedTaskScheduler>();
services.TryAddSingleton<UIExtensionsRegistry>();
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
- services.AddSingleton<IDbContextFactory<ApplicationDbContext>, ApplicationDbContextFactory>((provider) => provider.GetRequiredService<ApplicationDbContextFactory>());
+ services.AddSingleton<IDbContextFactory<ApplicationDbContext>, ApplicationDbContextFactory>((provider) =>
+ provider.GetRequiredService<ApplicationDbContextFactory>());
services.AddSingleton<IMigrationExecutor, MigrationExecutor<ApplicationDbContext>>();
services.AddDbContext<ApplicationDbContext>((provider, o) =>
{
@@ -161,9 +168,9 @@ namespace BTCPayServer.Hosting
services.AddStartupTask<MigrationStartupTask>();
//
- AddSettingsAccessor<PoliciesSettings>(services);
- AddSettingsAccessor<ThemeSettings>(services);
- AddSettingsAccessor<ServerSettings>(services);
+ services.AddSettingsAccessor<PoliciesSettings>();
+ services.AddSettingsAccessor<ThemeSettings>();
+ services.AddSettingsAccessor<ServerSettings>();
//
AddOnchainWalletParsers(services);
@@ -186,51 +193,47 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<WalletHistogramService>();
services.TryAddSingleton<LightningHistogramService>();
services.AddSingleton<ApplicationDbContextFactory>();
- services.AddOptions<BTCPayServerOptions>().Configure(
- (options) =>
- {
- options.LoadArgs(configuration, logs);
- });
- services.AddOptions<DataDirectories>().Configure(
- (options) =>
+ services.AddOptions<BTCPayServerOptions>().Configure((options) =>
+ {
+ options.LoadArgs(configuration, logs);
+ });
+ services.AddOptions<DataDirectories>().Configure((options) =>
+ {
+ options.Configure(configuration);
+ });
+ services.AddOptions<DatabaseOptions>().Configure<IOptions<DataDirectories>>((options, datadirs) =>
+ {
+ var postgresConnectionString = configuration["postgres"];
+ if (!string.IsNullOrEmpty(postgresConnectionString))
{
- options.Configure(configuration);
- });
- services.AddOptions<DatabaseOptions>().Configure<IOptions<DataDirectories>>(
- (options, datadirs) =>
+ options.ConnectionString = postgresConnectionString;
+ }
+ else
{
- var postgresConnectionString = configuration["postgres"];
- if (!string.IsNullOrEmpty(postgresConnectionString))
- {
- options.ConnectionString = postgresConnectionString;
- }
- else
- {
- throw new InvalidOperationException("No database option was configured.");
- }
- });
- services.AddOptions<NBXplorerOptions>().Configure<BTCPayNetworkProvider>(
- (options, btcPayNetworkProvider) =>
+ throw new InvalidOperationException("No database option was configured.");
+ }
+ });
+ services.AddOptions<NBXplorerOptions>().Configure<BTCPayNetworkProvider>((options, btcPayNetworkProvider) =>
+ {
+ foreach (BTCPayNetwork btcPayNetwork in btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>())
{
- foreach (BTCPayNetwork btcPayNetwork in btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>())
- {
- NBXplorerConnectionSetting setting =
- new NBXplorerConnectionSetting
- {
- CryptoCode = btcPayNetwork.CryptoCode,
- ExplorerUri = configuration.GetOrDefault<Uri>(
- $"{btcPayNetwork.CryptoCode}.explorer.url",
- btcPayNetwork.NBXplorerNetwork.DefaultSettings.DefaultUrl),
- CookieFile = configuration.GetOrDefault<string>(
- $"{btcPayNetwork.CryptoCode}.explorer.cookiefile",
- btcPayNetwork.NBXplorerNetwork.DefaultSettings.DefaultCookieFile)
- };
- options.NBXplorerConnectionSettings.Add(setting);
- options.ConnectionString = configuration.GetOrDefault<string>("explorer.postgres", null);
- }
- });
- services.AddOptions<LightningNetworkOptions>().Configure<BTCPayNetworkProvider, LightningClientFactoryService>(
- (options, btcPayNetworkProvider, lightningClientFactoryService) =>
+ NBXplorerConnectionSetting setting =
+ new NBXplorerConnectionSetting
+ {
+ CryptoCode = btcPayNetwork.CryptoCode,
+ ExplorerUri = configuration.GetOrDefault<Uri>(
+ $"{btcPayNetwork.CryptoCode}.explorer.url",
+ btcPayNetwork.NBXplorerNetwork.DefaultSettings.DefaultUrl),
+ CookieFile = configuration.GetOrDefault<string>(
+ $"{btcPayNetwork.CryptoCode}.explorer.cookiefile",
+ btcPayNetwork.NBXplorerNetwork.DefaultSettings.DefaultCookieFile)
+ };
+ options.NBXplorerConnectionSettings.Add(setting);
+ options.ConnectionString = configuration.GetOrDefault<string>("explorer.postgres", null);
+ }
+ });
+ services.AddOptions<LightningNetworkOptions>()
+ .Configure<BTCPayNetworkProvider, LightningClientFactoryService>((options, btcPayNetworkProvider, lightningClientFactoryService) =>
{
foreach (var net in btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>())
{
@@ -276,35 +279,35 @@ namespace BTCPayServer.Hosting
logs.Configuration.LogWarning(
$"Setting {net.CryptoCode}.lightning is a deprecated format ({lightning}), it will work now, but please replace it for future versions with '{lightningClient}'");
}
+
options.InternalLightningByCryptoCode.Add(net.CryptoCode, lightningClient);
}
}
}
});
- services.AddOptions<ExternalServicesOptions>().Configure<BTCPayNetworkProvider>(
- (options, btcPayNetworkProvider) =>
+ services.AddOptions<ExternalServicesOptions>().Configure<BTCPayNetworkProvider>((options, btcPayNetworkProvider) =>
+ {
+ foreach (var net in btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>())
{
- foreach (var net in btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>())
- {
- options.ExternalServices.Load(net.CryptoCode, configuration);
- }
+ options.ExternalServices.Load(net.CryptoCode, configuration);
+ }
- options.ExternalServices.LoadNonCryptoServices(configuration);
+ options.ExternalServices.LoadNonCryptoServices(configuration);
- var services = configuration.GetOrDefault<string>("externalservices", null);
- if (services != null)
+ var services = configuration.GetOrDefault<string>("externalservices", null);
+ if (services != null)
+ {
+ foreach (var service in services.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => (p, SeparatorIndex: p.IndexOf(':', StringComparison.OrdinalIgnoreCase)))
+ .Where(p => p.SeparatorIndex != -1)
+ .Select(p => (Name: p.p.Substring(0, p.SeparatorIndex),
+ Link: p.p.Substring(p.SeparatorIndex + 1))))
{
- foreach (var service in services.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(p => (p, SeparatorIndex: p.IndexOf(':', StringComparison.OrdinalIgnoreCase)))
- .Where(p => p.SeparatorIndex != -1)
- .Select(p => (Name: p.p.Substring(0, p.SeparatorIndex),
- Link: p.p.Substring(p.SeparatorIndex + 1))))
- {
- if (Uri.TryCreate(service.Link, UriKind.RelativeOrAbsolute, out var uri))
- options.OtherExternalServices.AddOrReplace(service.Name, uri);
- }
+ if (Uri.TryCreate(service.Link, UriKind.RelativeOrAbsolute, out var uri))
+ options.OtherExternalServices.AddOrReplace(service.Name, uri);
}
- });
+ }
+ });
services.TryAddSingleton<BTCPayNetworkProvider>();
services.AddExceptionHandler<PluginExceptionHandler>();
@@ -402,11 +405,11 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<RefundsReportProvider>();
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
- o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
+ o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentLinkExtension>>(o =>
-o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
+ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
services.AddSingleton<Dictionary<PaymentMethodId, ICheckoutModelExtension>>(o =>
- o.GetRequiredService<IEnumerable<ICheckoutModelExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
+ o.GetRequiredService<IEnumerable<ICheckoutModelExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
@@ -485,6 +488,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes();
+
services.AddAuthorization(o => o.AddBTCPayPolicies());
services.AddCors(options =>
@@ -500,7 +504,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
Serilog.Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Is(BTCPayServerOptions.GetDebugLogLevel(configuration))
- .WriteTo.File(debugLogFile, rollingInterval: RollingInterval.Day, fileSizeLimitBytes: MAX_DEBUG_LOG_FILE_SIZE, rollOnFileSizeLimit: true, retainedFileCountLimit: 1)
+ .WriteTo.File(debugLogFile, rollingInterval: RollingInterval.Day, fileSizeLimitBytes: MAX_DEBUG_LOG_FILE_SIZE,
+ rollOnFileSizeLimit: true, retainedFileCountLimit: 1)
.CreateLogger();
logBuilder.AddProvider(new Serilog.Extensions.Logging.SerilogLoggerProvider(Log.Logger));
}
@@ -522,23 +527,23 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
public static void RegisterExchangeRecommendations(IServiceCollection services)
{
foreach (var rule in new Dictionary<string, string>()
- {
- { "EUR", "kraken" },
- { "USD", "kraken" },
- { "CAD", "kraken" },
- { "GBP", "kraken" },
- { "CHF", "kraken" },
- { "GTQ", "bitpay" },
- { "COP", "yadio" },
- { "ARS", "yadio" },
- { "JPY", "bitbank" },
- { "TRY", "btcturk" },
- { "UGX", "yadio"},
- { "RSD", "bitpay"},
- { "NGN", "bitnob"},
- { "NOK", "barebitcoin"},
- { "CZK", "coinmate"},
- })
+ {
+ { "EUR", "kraken" },
+ { "USD", "kraken" },
+ { "CAD", "kraken" },
+ { "GBP", "kraken" },
+ { "CHF", "kraken" },
+ { "GTQ", "bitpay" },
+ { "COP", "yadio" },
+ { "ARS", "yadio" },
+ { "JPY", "bitbank" },
+ { "TRY", "btcturk" },
+ { "UGX", "yadio" },
+ { "RSD", "bitpay" },
+ { "NGN", "bitnob" },
+ { "NOK", "barebitcoin" },
+ { "CZK", "coinmate" },
+ })
{
var r = new DefaultRules.Recommendation(rule.Key, rule.Value);
r.Order = DefaultRules.HardcodedRecommendedExchangeOrder;
@@ -562,7 +567,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
internal static void RegisterCurrencyData(IServiceCollection services)
{
services.TryAddSingleton<CurrencyNameTable>();
- services.AddSingleton<CurrencyDataProvider, AssemblyCurrencyDataProvider>(c => new AssemblyCurrencyDataProvider(typeof(BTCPayServer.Rating.BidAsk).Assembly, "BTCPayServer.Rating.Currencies.json"));
+ services.AddSingleton<CurrencyDataProvider, AssemblyCurrencyDataProvider>(c =>
+ new AssemblyCurrencyDataProvider(typeof(BTCPayServer.Rating.BidAsk).Assembly, "BTCPayServer.Rating.Currencies.json"));
}
internal static void RegisterRateSources(IServiceCollection services)
@@ -572,7 +578,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddRateProviderExchangeSharp<ExchangePoloniexAPI>(new("poloniex", "Poloniex", " https://api.poloniex.com/markets/price"));
services.AddRateProviderExchangeSharp<ExchangeNDAXAPI>(new("ndax", "NDAX", "https://ndax.io/api/returnTicker"));
- services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex", "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,PRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0"));
+ services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex",
+ "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,PRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0"));
services.AddRateProviderExchangeSharp<ExchangeOKExAPI>(new("okex", "OKEx", "https://www.okex.com/api/futures/v3/instruments/ticker"));
services.AddRateProviderExchangeSharp<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products"));
@@ -615,6 +622,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
{
services.AddSingleton<IRateProvider, T>();
}
+
public static IServiceCollection AddBTCPayNetwork(this IServiceCollection services, BTCPayNetworkBase network)
{
services.AddSingleton(new DefaultRules(network.DefaultRateRules));
@@ -627,6 +635,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<CurrencyDataProvider, InMemoryCurrencyDataProvider>(c => new InMemoryCurrencyDataProvider(currencyData));
return services;
}
+
public static IServiceCollection AddBTCPayNetwork(this IServiceCollection services, BTCPayNetwork network)
{
services.AddSingleton(new DefaultRules(network.DefaultRateRules));
@@ -636,22 +645,26 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddDefaultPrettyName(pmi, network.DisplayName);
services.AddSingleton<BTCPayNetworkBase>(network);
services.AddSingleton<IPaymentMethodHandler>(provider =>
- (BitcoinLikePaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinLikePaymentHandler), new object[] { network, pmi }));
+ (BitcoinLikePaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinLikePaymentHandler), new object[] { network, pmi }));
services.AddSingleton<IPaymentLinkExtension>(provider =>
-(IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinPaymentLinkExtension), new object[] { network, pmi }));
+ (IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinPaymentLinkExtension), new object[] { network, pmi }));
services.AddSingleton<ICheckoutModelExtension>(provider =>
- (BitcoinCheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinCheckoutModelExtension), new object[] { network, pmi }));
+ (BitcoinCheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinCheckoutModelExtension),
+ new object[] { network, pmi }));
services.AddSingleton<IPaymentMethodBitpayAPIExtension>(provider =>
-(IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinPaymentMethodBitpayAPIExtension), new object[] { pmi }));
+ (IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinPaymentMethodBitpayAPIExtension),
+ new object[] { pmi }));
services.AddSingleton<ICheckoutCheatModeExtension>(provider =>
-(ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinCheckoutCheatModeExtension), new object[] { network }));
+ (ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinCheckoutCheatModeExtension),
+ new object[] { network }));
if (!network.ReadonlyWallet && network.WalletSupported)
{
var payoutMethodId = PayoutTypes.CHAIN.GetPayoutMethodId(network.CryptoCode);
services.AddSingleton<IPayoutHandler>(provider =>
- (IPayoutHandler)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinLikePayoutHandler), new object[] { payoutMethodId, network }));
+ (IPayoutHandler)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinLikePayoutHandler),
+ new object[] { payoutMethodId, network }));
}
}
if (network.NBitcoinNetwork.Consensus.SupportSegwit && network.SupportLightning)
@@ -664,18 +677,23 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
else
services.AddDefaultPrettyName(pmi, $"Lightning ({network.DisplayName})");
services.AddSingleton<IPaymentMethodHandler>(provider =>
- (LightningLikePaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(LightningLikePaymentHandler), new object[] { network, pmi }));
+ (LightningLikePaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(LightningLikePaymentHandler),
+ new object[] { network, pmi }));
services.AddSingleton<IPaymentLinkExtension>(provider =>
-(IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningPaymentLinkExtension), new object[] { network, pmi }));
+ (IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningPaymentLinkExtension),
+ new object[] { network, pmi }));
services.AddSingleton<ICheckoutModelExtension>(provider =>
- (ICheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNCheckoutModelExtension), new object[] { network, pmi }));
+ (ICheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNCheckoutModelExtension), new object[] { network, pmi }));
services.AddSingleton<IPaymentMethodBitpayAPIExtension>(provider =>
-(IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningPaymentMethodBitpayAPIExtension), new object[] { pmi }));
+ (IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningPaymentMethodBitpayAPIExtension),
+ new object[] { pmi }));
var payoutMethodId = PayoutTypes.LN.GetPayoutMethodId(network.CryptoCode);
services.AddSingleton<IPayoutHandler>(provider =>
- (IPayoutHandler)ActivatorUtilities.CreateInstance(provider, typeof(LightningLikePayoutHandler), new object[] { payoutMethodId, network }));
+ (IPayoutHandler)ActivatorUtilities.CreateInstance(provider, typeof(LightningLikePayoutHandler),
+ new object[] { payoutMethodId, network }));
services.AddSingleton<ICheckoutCheatModeExtension>(provider =>
-(ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningCheckoutCheatModeExtension), new object[] { network }));
+ (ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningCheckoutCheatModeExtension),
+ new object[] { network }));
}
// LNURL
{
@@ -685,24 +703,31 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
else
services.AddDefaultPrettyName(pmi, $"Lightning ({network.DisplayName} via LNURL)");
services.AddSingleton<IPaymentMethodHandler>(provider =>
- (LNURLPayPaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentHandler), new object[] { network, pmi }));
+ (LNURLPayPaymentHandler)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentHandler), new object[] { network, pmi }));
services.AddSingleton<IPaymentLinkExtension>(provider =>
- (IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentLinkExtension), new object[] { network, pmi }));
+ (IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentLinkExtension),
+ new object[] { network, pmi }));
services.AddSingleton<ICheckoutModelExtension>(provider =>
-(ICheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLCheckoutModelExtension), new object[] { network, pmi }));
+ (ICheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLCheckoutModelExtension),
+ new object[] { network, pmi }));
services.AddSingleton<IPaymentMethodBitpayAPIExtension>(provider =>
-(IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentMethodBitpayAPIExtension), new object[] { pmi }));
+ (IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(LNURLPayPaymentMethodBitpayAPIExtension),
+ new object[] { pmi }));
}
}
+
return services;
}
+
public static void AddTransactionLinkProvider(this IServiceCollection services, PaymentMethodId paymentMethodId, TransactionLinkProvider provider)
{
services.AddSingleton<TransactionLinkProviders.Entry>(new TransactionLinkProviders.Entry(paymentMethodId, provider));
}
+
[Obsolete("Use AddTransactionLinkProvider(services, PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), provider) instead")]
public static void AddTransactionLinkProvider(this IServiceCollection services, string cryptoCode, TransactionLinkProvider provider) =>
AddTransactionLinkProvider(services, PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), provider);
+
public static void AddRateProviderExchangeSharp<T>(this IServiceCollection services, RateSourceInfo rateInfo) where T : ExchangeSharp.ExchangeAPI
{
services.AddSingleton<IRateProvider, ExchangeSharpRateProvider<T>>(o =>
@@ -713,23 +738,39 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
});
}
- private static void AddSettingsAccessor<T>(IServiceCollection services) where T : class, new()
- {
- services.TryAddSingleton<ISettingsAccessor<T>, SettingsAccessor<T>>();
- services.AddSingleton<IHostedService>(provider => (SettingsAccessor<T>)provider.GetRequiredService<ISettingsAccessor<T>>());
- services.AddSingleton<IStartupTask>(provider => (SettingsAccessor<T>)provider.GetRequiredService<ISettingsAccessor<T>>());
- // Singletons shouldn't reference the settings directly, but ISettingsAccessor<T>, since singletons won't have refreshed values of the setting
- services.AddTransient<T>(provider => provider.GetRequiredService<ISettingsAccessor<T>>().Settings);
- }
-
public static void SkipModelValidation<T>(this IServiceCollection services)
{
services.AddSingleton<SkippableObjectValidatorProvider.ISkipValidation, SkippableObjectValidatorProvider.SkipValidationType<T>>();
}
+
private const long MAX_DEBUG_LOG_FILE_SIZE = 2000000; // If debug log is in use roll it every N MB.
+
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services)
{
+ services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, opt =>
+ {
+ opt.LoginPath = "/login";
+ opt.AccessDeniedPath = "/errors/403";
+ opt.LogoutPath = "/logout";
+ });
services.AddAuthentication()
+ .AddCookie(AuthenticationSchemes.LimitedLogin, options =>
+ {
+ options.Cookie.Name = "pwd_verified";
+ options.ExpireTimeSpan = TimeSpan.FromMinutes(60); // short-lived
+ options.SlidingExpiration = false;
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
+ options.Events.OnRedirectToLogin = context =>
+ {
+ context.RedirectUri = QueryHelpers.AddQueryString(context.RedirectUri, [KeyValuePair.Create("allowLimitedLogin", "true")]);
+ context.Response.Redirect(context.RedirectUri);
+ return Task.CompletedTask;
+ };
+ options.LoginPath = "/login";
+ options.AccessDeniedPath = "/errors/403";
+ options.LogoutPath = "/logout";
+ })
.AddBitpayAuthentication()
.AddAPIKeyAuthentication();
}
@@ -740,6 +781,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
app.UseMiddleware<BTCPayMiddleware>();
return app;
}
+
public static IApplicationBuilder UseHeadersOverride(this IApplicationBuilder app)
{
app.UseMiddleware<HeadersOverrideMiddleware>();
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 037460f..ff3125d 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -102,12 +102,6 @@ namespace BTCPayServer.Hosting
opts.DefaultSignInScheme = null;
opts.DefaultSignOutScheme = null;
});
- services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, opt =>
- {
- opt.LoginPath = "/login";
- opt.AccessDeniedPath = "/errors/403";
- opt.LogoutPath = "/logout";
- });
services.Configure<SecurityStampValidatorOptions>(opts =>
{
diff --git a/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs b/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
index 0da5b08..9509523 100644
--- a/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
+++ b/BTCPayServer/Models/AccountViewModels/LoginViewModel.cs
@@ -17,5 +17,7 @@ namespace BTCPayServer.Models.AccountViewModels
[Display(Name = "Remember me")]
public bool RememberMe { get; set; }
+
+ public bool AllowLimitedLogin { get; set; }
}
}
diff --git a/BTCPayServer/Models/ServerViewModels/BrandingViewModel.cs b/BTCPayServer/Models/ServerViewModels/BrandingViewModel.cs
index fae4604..0ed4144 100644
--- a/BTCPayServer/Models/ServerViewModels/BrandingViewModel.cs
+++ b/BTCPayServer/Models/ServerViewModels/BrandingViewModel.cs
@@ -32,4 +32,6 @@ public class BrandingViewModel
public IFormFile LogoFile { get; set; }
public string LogoUrl { get; set; }
+ [Display(Name = "Base URL")]
+ public string BaseUrl { get; set; }
}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs b/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs
index f22dbbb..78983ca 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs
@@ -82,7 +82,8 @@ public class UIEmailRuleControllerBase(
Trigger = trigger,
OfferingId = offeringId,
RedirectUrl = redirectUrl,
- To = to
+ To = to,
+ IsNew = true
});
}
@@ -109,7 +110,10 @@ public class UIEmailRuleControllerBase(
CC = model.AsArray(model.CC),
BCC = model.AsArray(model.BCC),
};
+#pragma warning disable CS0618 // Type or member is obsolete
+ // Safe, we just created the instance, nobody modifying at same time
c.SetBTCPayAdditionalData(model.AdditionalData);
+#pragma warning restore CS0618 // Type or member is obsolete
ctx.EmailRules.Add(c);
await ctx.SaveChangesAsync();
@@ -127,7 +131,8 @@ public class UIEmailRuleControllerBase(
{
CanChangeTrigger = r.OfferingId is null,
CanChangeCondition = r.OfferingId is null,
- RedirectUrl = redirectUrl
+ RedirectUrl = redirectUrl,
+ IsNew = false
});
}
@@ -142,7 +147,10 @@ public class UIEmailRuleControllerBase(
if (rule is null) return NotFound();
rule.Trigger = model.Trigger;
+#pragma warning disable CS0618 // Type or member is obsolete
+ // TODO: Do direct db update to avoid race condition
rule.SetBTCPayAdditionalData(model.AdditionalData);
+#pragma warning restore CS0618 // Type or member is obsolete
rule.To = model.AsArray(model.To);
rule.CC = model.AsArray(model.CC);
rule.BCC = model.AsArray(model.BCC);
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs
index 4c9d53d..41679b5 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs
@@ -21,7 +21,7 @@ namespace BTCPayServer.Plugins.Emails.Controllers;
[AutoValidateAntiforgeryToken]
public class UIServerEmailRulesController(
EmailSenderFactory emailSenderFactory,
- IEnumerable<EmailTriggerViewModel> triggers,
+ EmailTriggerViewModels triggers,
ApplicationDbContextFactory dbContextFactory,
IStringLocalizer stringLocalizer
) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
@@ -36,7 +36,7 @@ public class UIServerEmailRulesController(
{
EmailSettingsLink = Url.Action(nameof(UIServerEmailController.ServerEmailSettings), "UIServerEmail") ?? throw new InvalidOperationException("Bug 1928"),
Rules = (ctx) => ctx.EmailRules.GetServerRules().ToListAsync(),
- Triggers = triggers.Where(t => t.ServerTrigger).ToList(),
+ Triggers = triggers.GetViewModels().Where(t => t.ServerTrigger).ToList(),
ModifyViewModel = (vm) =>
{
vm.ShowCustomerEmailColumn = false;
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
index fc6dad7..c5b3c6c 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
@@ -24,7 +24,7 @@ public class UIStoreEmailRulesController(
EmailSenderFactory emailSenderFactory,
LinkGenerator linkGenerator,
ApplicationDbContextFactory dbContextFactory,
- IEnumerable<EmailTriggerViewModel> triggers,
+ EmailTriggerViewModels triggers,
IStringLocalizer stringLocalizer) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
{
[HttpGet("")]
@@ -37,7 +37,7 @@ public class UIStoreEmailRulesController(
StoreId = storeId,
EmailSettingsLink = linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl()),
Rules = (ctx) => ctx.EmailRules.GetRules(storeId).ToListAsync(),
- Triggers = triggers.Where(t => !t.ServerTrigger).ToList(),
+ Triggers = triggers.GetViewModels().Where(t => !t.ServerTrigger).ToList(),
ModifyViewModel = (vm) =>
{
vm.ShowCustomerEmailColumn = true;
diff --git a/BTCPayServer/Plugins/Emails/EmailTriggerTransformers.cs b/BTCPayServer/Plugins/Emails/EmailTriggerTransformers.cs
new file mode 100644
index 0000000..c071c1b
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/EmailTriggerTransformers.cs
@@ -0,0 +1,59 @@
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Services;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public class ServerTransformer(ISettingsAccessor<ServerSettings> serverSettings) : IEmailTriggerViewModelTransformer, IEmailTriggerEventTransformer
+{
+ public void Transform(EmailTriggerViewModel viewModel)
+ {
+ viewModel.PlaceHolders.Add(new("{Server.Name}", ServerNameDoc));
+ viewModel.PlaceHolders.Add(new("{Server.ContactUrl}", ContactUrlDoc));
+ viewModel.PlaceHolders.Add(new("{Server.BaseUrl}", BaseUrlDoc));
+ }
+
+ public const string ServerNameDoc = "The name of the server (Server Settings ➡ Branding)";
+ public const string ContactUrlDoc = "The contact URL of the server (Server Settings ➡ Branding)";
+ public const string BaseUrlDoc = "The base URL of this server (Server Settings ➡ Branding)";
+ public static string[] TranslatedStrings => new[] { ServerNameDoc, ContactUrlDoc, BaseUrlDoc };
+ public void Transform(IEmailTriggerEventTransformer.Context context)
+ {
+ var serverObj = (JObject)(context.TriggerEvent.Model["Server"] ??= new JObject());
+ serverObj["Name"] = serverSettings.Settings.ServerName ?? "BTCPay Server";
+ serverObj["ContactUrl"] = serverSettings.Settings.ContactUrl;
+ serverObj["BaseUrl"] = serverSettings.Settings.BaseUrl;
+ }
+}
+
+public class StoreTransformer : IEmailTriggerViewModelTransformer, IEmailTriggerEventTransformer
+{
+ public void Transform(EmailTriggerViewModel viewModel)
+ {
+ if (!viewModel.ServerTrigger)
+ {
+ viewModel.PlaceHolders.Add(new("{Store.Id}", StoreIdDoc));
+ viewModel.PlaceHolders.Add(new("{Store.Name}", StoreNameDoc));
+ viewModel.PlaceHolders.Add(new("{Store.WebsiteUrl}", StoreUrlDoc));
+ viewModel.PlaceHolders.Add(new("{Store.SupportUrl}", SupportUrlDoc));
+ }
+ }
+
+ public const string StoreNameDoc = "The name of the store";
+ public const string StoreIdDoc = "The id of the store";
+ public const string StoreUrlDoc = "The website of the store";
+ public const string SupportUrlDoc = "The support url of the store";
+ public static string[] TranslatedStrings => new[] { StoreIdDoc, StoreNameDoc, StoreUrlDoc, SupportUrlDoc };
+ public void Transform(IEmailTriggerEventTransformer.Context context)
+ {
+ if (context.Store is { } store)
+ {
+ var storeObj = (JObject)(context.TriggerEvent.Model["Store"] ??= new JObject());
+ storeObj["Id"] = store.Id;
+ storeObj["Name"] = store.StoreName;
+ storeObj["SupportUrl"] = store.GetStoreBlob().StoreSupportUrl;
+ storeObj["WebsiteUrl"] = store.StoreWebsite;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/EmailsExtensions.cs b/BTCPayServer/Plugins/Emails/EmailsExtensions.cs
deleted file mode 100644
index 76e51b1..0000000
--- a/BTCPayServer/Plugins/Emails/EmailsExtensions.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Plugins.Emails.Views;
-
-namespace BTCPayServer;
-
-public static class EmailsExtensions
-{
- public static List<EmailTriggerViewModel.PlaceHolder> AddStoresPlaceHolders(this List<EmailTriggerViewModel.PlaceHolder> placeholders)
- {
- placeholders.Insert(0, new("{Store.Name}", "The name of the store"));
- placeholders.Insert(0, new("{Store.Id}", "The id of the store"));
- return placeholders;
- }
-}
diff --git a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
index 6fb5431..532c8d3 100644
--- a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
+++ b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
@@ -3,7 +3,6 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Plugins.Emails.Views;
-using BTCPayServer.Plugins.Webhooks;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -21,21 +20,40 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
{
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
+ services.AddTransient<EmailTriggerViewModels>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddMigration<ApplicationDbContext, DefaultServerEmailRulesMigration>();
+
+ services.AddSingleton<IEmailTriggerViewModelTransformer, ServerTransformer>();
+ services.AddSingleton<IEmailTriggerEventTransformer, ServerTransformer>();
+ services.AddDefaultTranslations(ServerTransformer.TranslatedStrings);
+
+ services.AddSingleton<IEmailTriggerViewModelTransformer, StoreTransformer>();
+ services.AddSingleton<IEmailTriggerEventTransformer, StoreTransformer>();
+ services.AddDefaultTranslations(StoreTransformer.TranslatedStrings);
+
+
RegisterServerEmailTriggers(services);
}
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
- private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>{Branding.ServerName}</h1><br/>";
+ private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>{Server.Name}</h1><br/>";
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
- private static string CallToAction(string actionName, string actionLink)
+ public static string CallToAction(string actionName, string actionLink)
{
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
return button.Replace("{button_link}", actionLink, System.StringComparison.InvariantCulture);
}
- private static string CreateEmailBody(string body) => $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
+ public static string CreateEmailBody(string body) => $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
+
+ public static string CreateEmail(string body, string actionName = null, string actionLink = null)
+ {
+ if (actionName is null || actionLink is null)
+ return CreateEmailBody(body);
+ return CreateEmailBody($"{body}<br/><br/>{CallToAction(actionName, actionLink)}");
+ }
+
private void RegisterServerEmailTriggers(IServiceCollection services)
{
@@ -48,7 +66,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
{
To = ["{User.MailboxAddress}"],
Subject = "Update Password",
- Body = CreateEmailBody($"A request has been made to reset your {{Branding.ServerName}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
+ Body = CreateEmailBody($"A request has been made to reset your {{Server.Name}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
},
PlaceHolders = new()
{
@@ -81,7 +99,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
DefaultEmail = new()
{
To = ["{User.MailboxAddress}"],
- Subject = "Invitation to join {Branding.ServerName}",
+ Subject = "Invitation to join {Server.Name}",
Body = CreateEmailBody($"<p>Please complete your account setup by clicking <a href='{{InvitationLink}}'>this link</a>.</p><p>You can also use the BTCPay Server app and scan this QR code when connecting:</p>{{InvitationLinkQR}}"),
},
PlaceHolders = new()
@@ -132,9 +150,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma"),
new("{User.Name}", "The name of the user (eg. John Doe)"),
new("{User.Email}", "The email of the user (eg. john.doe@example.com)"),
- new("{User.MailboxAddress}", "The formatted mailbox address to use when sending an email. (eg. \"John Doe\" <john.doe@example.com>)"),
- new("{Branding.ServerName}", "The name of the server (You can configure this in Server Settings ➡ Branding)"),
- new("{Branding.ContactUrl}", "The contact URL of the server (You can configure this in Server Settings ➡ Branding)"),
+ new("{User.MailboxAddress}", "The formatted mailbox address to use when sending an email. (eg. \"John Doe\" <john.doe@example.com>)")
};
foreach (var v in vms)
{
diff --git a/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs b/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
index 95c4ca6..8013568 100644
--- a/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
+++ b/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
@@ -8,11 +8,11 @@ using BTCPayServer.Services;
namespace BTCPayServer.Plugins.Emails;
-public class EmailsTranslationProvider(IEnumerable<EmailTriggerViewModel> viewModels) : IDefaultTranslationProvider
+public class EmailsTranslationProvider(EmailTriggerViewModels viewModels) : IDefaultTranslationProvider
{
public Task<KeyValuePair<string, string?>[]> GetDefaultTranslations()
=> Task.FromResult(
- viewModels.Select(vm => KeyValuePair.Create(vm.Description, vm.Description))
- .Concat(viewModels.SelectMany(vm => vm.PlaceHolders).Select(p => KeyValuePair.Create(p.Description, p.Description)))
+ viewModels.GetViewModels().Select(vm => KeyValuePair.Create(vm.Description, vm.Description))
+ .Concat(viewModels.GetViewModels().SelectMany(vm => vm.PlaceHolders).Select(p => KeyValuePair.Create(p.Description, p.Description)))
.ToArray())!;
}
diff --git a/BTCPayServer/Plugins/Emails/HostedServices/EmailRuleProcessorSender.cs b/BTCPayServer/Plugins/Emails/HostedServices/EmailRuleProcessorSender.cs
index 184b126..60bfd4a 100644
--- a/BTCPayServer/Plugins/Emails/HostedServices/EmailRuleProcessorSender.cs
+++ b/BTCPayServer/Plugins/Emails/HostedServices/EmailRuleProcessorSender.cs
@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mail;
+using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
@@ -21,6 +22,11 @@ public interface ITriggerOwner
public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner)
{
+ /// <summary>
+ /// The JSON path of properties in a model that shouldn't be HTML encoded.
+ /// </summary>
+ public HashSet<string> RawHtml { get; } = new(StringComparer.OrdinalIgnoreCase);
+
public override string ToString()
=> $"Trigger event '{Trigger}'";
}
@@ -41,6 +47,7 @@ public class StoreEmailRuleProcessorSender(
ApplicationDbContextFactory dbContextFactory,
EventAggregator eventAggregator,
ILogger<StoreEmailRuleProcessorSender> logger,
+ IEnumerable<IEmailTriggerEventTransformer> emailTriggerBodyTransformers,
EmailSenderFactory emailSenderFactory)
: EventHostedServiceBase(eventAggregator, logger)
{
@@ -54,6 +61,7 @@ public class StoreEmailRuleProcessorSender(
if (evt is TriggerEvent triggEvent)
{
await using var ctx = dbContextFactory.CreateContext();
+ await Transform(ctx, triggEvent);
var actionableRules = await ctx.EmailRules.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
if (actionableRules.Length > 0)
@@ -64,6 +72,12 @@ public class StoreEmailRuleProcessorSender(
var matchedContext = new EmailRuleMatchContext(triggEvent, actionableRule);
var body = new TextTemplate(actionableRule.Body ?? "");
+ body.Encode = (v) =>
+ {
+ if (triggEvent.RawHtml.Contains(v.Path))
+ return v.Value;
+ return HtmlEncoder.Default.Encode(v.Value);
+ };
var subject = new TextTemplate(actionableRule.Subject ?? "");
AddToMatchedContext(triggEvent.Model, matchedContext.To, actionableRule.To);
AddToMatchedContext(triggEvent.Model, matchedContext.CC, actionableRule.CC);
@@ -79,6 +93,14 @@ public class StoreEmailRuleProcessorSender(
}
}
+ private async Task Transform(ApplicationDbContext ctx, TriggerEvent triggEvent)
+ {
+ var store = triggEvent.StoreId is null ? null : await ctx.Stores.FindAsync(triggEvent.StoreId);
+ var context = new IEmailTriggerEventTransformer.Context(triggEvent, store);
+ foreach (var transformer in emailTriggerBodyTransformers)
+ transformer.Transform(context);
+ }
+
private void AddToMatchedContext(JObject model, List<MailboxAddress> mailboxAddresses, string[] rulesAddresses)
{
mailboxAddresses.AddRange(
diff --git a/BTCPayServer/Plugins/Emails/HostedServices/UserEventTriggerHostedService.cs b/BTCPayServer/Plugins/Emails/HostedServices/UserEventTriggerHostedService.cs
index 8303580..132a5cd 100644
--- a/BTCPayServer/Plugins/Emails/HostedServices/UserEventTriggerHostedService.cs
+++ b/BTCPayServer/Plugins/Emails/HostedServices/UserEventTriggerHostedService.cs
@@ -20,7 +20,6 @@ namespace BTCPayServer.Plugins.Emails.HostedServices;
public class UserEventHostedService(
EventAggregator eventAggregator,
IServiceScopeFactory serviceScopeFactory,
- ISettingsAccessor<ServerSettings> serverSettings,
NotificationSender notificationSender,
Logs logs)
: EventHostedServiceBase(eventAggregator, logs)
@@ -60,12 +59,16 @@ public class UserEventHostedService(
if (ev is UserEvent.Invited invited)
{
if (invited.SendInvitationEmail)
- EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.InvitePending,
+ {
+ var trigger = await CreateTriggerEvent(ServerMailTriggers.InvitePending,
new JObject()
{
- ["InvitationLink"] = HtmlEncoder.Default.Encode(invited.InvitationLink),
+ ["InvitationLink"] = invited.InvitationLink,
["InvitationLinkQR"] = GetQrCodeImg(invited.InvitationLink)
- }, user));
+ }, user);
+ trigger.RawHtml.Add("InvitationLinkQR");
+ EventAggregator.Publish(trigger);
+ }
}
else if (requiresEmailConfirmation)
{
@@ -76,7 +79,7 @@ public class UserEventHostedService(
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.EmailConfirm,
new JObject()
{
- ["ConfirmLink"] = HtmlEncoder.Default.Encode(confReq.ConfirmLink)
+ ["ConfirmLink"] = confReq.ConfirmLink
}, user));
break;
@@ -84,7 +87,7 @@ public class UserEventHostedService(
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.PasswordReset,
new JObject()
{
- ["ResetLink"] = HtmlEncoder.Default.Encode(pwResetEvent.ResetLink)
+ ["ResetLink"] = pwResetEvent.ResetLink
}, user));
break;
@@ -93,7 +96,7 @@ public class UserEventHostedService(
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalConfirmed,
new JObject()
{
- ["LoginLink"] = HtmlEncoder.Default.Encode(approvedEvent.LoginLink)
+ ["LoginLink"] = approvedEvent.LoginLink
}, user));
break;
@@ -110,7 +113,7 @@ public class UserEventHostedService(
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalRequest,
new JObject()
{
- ["ApprovalLink"] = HtmlEncoder.Default.Encode(approvalLink)
+ ["ApprovalLink"] = approvalLink
}, user));
}
@@ -129,11 +132,6 @@ public class UserEventHostedService(
["Name"] = user.UserName,
["Email"] = user.Email,
["MailboxAddress"] = user.GetMailboxAddress().ToString(),
- };
- model["Branding"] = new JObject()
- {
- ["ServerName"] = serverSettings.Settings.ServerName ?? "BTCPay Server",
- ["ContactUrl"] = serverSettings.Settings.ContactUrl,
};
var evt = new TriggerEvent(null, trigger, model, null);
return evt;
diff --git a/BTCPayServer/Plugins/Emails/IEmailTriggerEventTransformer.cs b/BTCPayServer/Plugins/Emails/IEmailTriggerEventTransformer.cs
new file mode 100644
index 0000000..8d84c39
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/IEmailTriggerEventTransformer.cs
@@ -0,0 +1,16 @@
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Emails.HostedServices;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public interface IEmailTriggerEventTransformer
+{
+ public class Context(TriggerEvent triggerEvent, StoreData store)
+ {
+ public TriggerEvent TriggerEvent { get; } = triggerEvent;
+ public StoreData Store { get; } = store;
+ }
+
+ void Transform(Context context);
+}
diff --git a/BTCPayServer/Plugins/Emails/IEmailTriggerViewModelTransformer.cs b/BTCPayServer/Plugins/Emails/IEmailTriggerViewModelTransformer.cs
new file mode 100644
index 0000000..ac0bf15
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/IEmailTriggerViewModelTransformer.cs
@@ -0,0 +1,7 @@
+using BTCPayServer.Plugins.Emails.Views;
+namespace BTCPayServer.Plugins.Emails;
+
+public interface IEmailTriggerViewModelTransformer
+{
+ void Transform(EmailTriggerViewModel viewModel);
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
index f086d93..165d0e9 100644
--- a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
@@ -35,4 +35,10 @@ public class EmailTriggerViewModel
public List<PlaceHolder> PlaceHolders { get; set; } = new();
public bool ServerTrigger { get; set; }
+
+ public EmailTriggerViewModel Clone()
+ {
+ var json = JsonConvert.SerializeObject(this);
+ return JsonConvert.DeserializeObject<EmailTriggerViewModel>(json);
+ }
}
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModels.cs b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModels.cs
new file mode 100644
index 0000000..b5b48e3
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModels.cs
@@ -0,0 +1,30 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+
+namespace BTCPayServer.Plugins.Emails.Views;
+
+public class EmailTriggerViewModels(
+ IEnumerable<IEmailTriggerViewModelTransformer> transformers,
+ IEnumerable<EmailTriggerViewModel> registeredTriggers)
+{
+ public class Context(EmailTriggerViewModel viewModel)
+ {
+ public EmailTriggerViewModel ViewModel { get; } = viewModel;
+ }
+
+ public List<EmailTriggerViewModel> GetViewModels()
+ => registeredTriggers
+ .Select(t => t.Clone())
+ .Select(Transform)
+ .ToList();
+
+ private EmailTriggerViewModel Transform(EmailTriggerViewModel arg)
+ {
+ foreach (var transformer in transformers)
+ {
+ transformer.Transform(arg);
+ }
+ return arg;
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml
index 4c764c4..7952ded 100644
--- a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml
+++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml
@@ -3,7 +3,7 @@
@{
var storeId = Model.StoreId;
- bool isEdit = Model.Trigger != null;
+ var isEdit = !Model.IsNew;
if (storeId is not null)
{
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
@@ -80,7 +80,7 @@
<input type="hidden" asp-for="CanChangeCondition" ></input>
@if (Model.CanChangeCondition)
{
- <input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Customer.Name == \"john\")"]" />
+ <input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $ ? (@.Customer.Name == \"John\"))"]" />
}
else
{
diff --git a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
index 3c22bce..32fd91f 100644
--- a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
@@ -59,7 +59,7 @@ public class StoreEmailRuleViewModel
public bool CanChangeCondition { get; set; } = true;
public string OfferingId { get; set; }
public string StoreId { get; set; }
-
+ public bool IsNew { get; set; }
public string[] AsArray(string values)
{
// This replace the placeholders with random email addresses
diff --git a/BTCPayServer/Plugins/Monetization/Controllers/UIServerMonetizationController.cs b/BTCPayServer/Plugins/Monetization/Controllers/UIServerMonetizationController.cs
new file mode 100644
index 0000000..96e59bf
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Controllers/UIServerMonetizationController.cs
@@ -0,0 +1,377 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Plugins.Monetization.Views;
+using BTCPayServer.Plugins.Subscriptions;
+using BTCPayServer.Plugins.Subscriptions.Controllers;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Apps;
+using BTCPayServer.Services.Rates;
+using BTCPayServer.Services.Stores;
+using BTCPayServer.Views.UIStoreMembership;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Localization;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Localization;
+using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
+
+namespace BTCPayServer.Plugins.Monetization.Controllers;
+
+[Authorize(Policy = Client.Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Route("server/monetization")]
+[Area(MonetizationPlugin.Area)]
+public class UIServerMonetizationController(
+ ApplicationDbContext ctx,
+ MonetizationHostedService monetizationService,
+ SettingsRepository settingsRepository,
+ AppService appService,
+ CurrencyNameTable currencyNameTable,
+ UserManager<ApplicationUser> userManager,
+ StoreRepository storeRepo,
+ ViewLocalizer viewLocalizer,
+ EmailSenderFactory emailSenderFactory,
+ LinkGenerator linkGenerator,
+ IStringLocalizer stringLocalizer) : Controller
+{
+ public ViewLocalizer ViewLocalizer { get; } = viewLocalizer;
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ [HttpGet]
+ public async Task<IActionResult> Monetization()
+ {
+ var settings = await settingsRepository.GetSettingAsync<MonetizationSettings>() ?? new();
+ var vm = new MonetizationViewModel()
+ {
+ Settings = settings
+ };
+
+ var offeringAndPlan = await ctx.GetOfferingAndPlan(settings);
+ vm.DefaultPlan = offeringAndPlan?.Plan;
+ vm.Offering = offeringAndPlan?.Offering;
+
+ var activePlans = vm.Offering?.Plans.Where(p => p.Status == PlanData.PlanStatus.Active).ToArray() ?? [];
+ vm.EmailServerConfigured = (await (await emailSenderFactory.GetEmailSender()).GetEmailSettings())?.IsComplete() is true;
+ vm.EmailStoreConfigured = vm.Offering is not null &&
+ (await (await emailSenderFactory.GetEmailSender(vm.Offering.App.StoreDataId)).GetEmailSettings())?.IsComplete() is true;
+
+ vm.Step =
+ offeringAndPlan is null ? MonetizationViewModel.InstallStatus.SetOffering :
+ !vm.EmailServerConfigured ? MonetizationViewModel.InstallStatus.ConfigureServerEmail :
+ !vm.EmailStoreConfigured ? MonetizationViewModel.InstallStatus.ConfigureStoreEmail :
+ MonetizationViewModel.InstallStatus.Done;
+
+ HashSet<string> canLogin = new();
+ if (vm.Offering is not null)
+ {
+ var planIds = activePlans.Select(p => p.Id).Distinct().ToArray();
+ canLogin = (await ctx.PlanEntitlements.Where(p => planIds.Contains(p.PlanId))
+ .Where(o => o.Entitlement.CustomId == MonetizationEntitlements.CanAccess)
+ .Select(o => o.PlanId)
+ .ToArrayAsync()).ToHashSet();
+ }
+
+ var stores = await storeRepo.GetStoresByUserId(userManager.GetUserId(User)!);
+ if (vm.Offering is null)
+ {
+ var vmSelect = new SelectExistingOfferingModalViewModel();
+ var storeIds = stores.Select(s => s.Id).ToArray();
+ var offerings = await ctx
+ .Offerings
+ .Include(o => o.App)
+ .Include(o => o.Plans)
+ .Where(o => storeIds.Contains(o.App.StoreDataId))
+ .ToArrayAsync();
+ var offeringsByStores = offerings.GroupBy(o => o.App.StoreDataId).ToDictionary(o => o.Key, o => o.ToArray());
+ vmSelect.Stores =
+ stores
+ .Where(s => offeringsByStores.ContainsKey(s.Id))
+ .Select(s => new SelectExistingOfferingModalViewModel.Store()
+ {
+ Id = s.Id,
+ Name = s.StoreName,
+ Offerings = offeringsByStores[s.Id]
+ .Where(o => !o.App.Archived)
+ .Select(o => new SelectExistingOfferingModalViewModel.Offering()
+ {
+ Id = o.Id,
+ Name = o.App.Name,
+ Plans = o.Plans
+ .Where(p => p.Status == PlanData.PlanStatus.Active)
+ .Select(p => new SelectExistingOfferingModalViewModel.Item()
+ {
+ Id = p.Id,
+ Name = p.Name
+ })
+ .OrderBy(p => p.Name)
+ .ToList()
+ })
+ .OrderBy(p => p.Name)
+ .ToList()
+ })
+ .OrderBy(p => p.Name)
+ .ToList();
+
+ // Remove empty
+ foreach (var store in vmSelect.Stores)
+ {
+ store.Offerings = store.Offerings.Where(o => o.Plans.Count != 0).ToList();
+ }
+
+ vmSelect.Stores = vmSelect.Stores.Where(s => s.Offerings.Count != 0).ToList();
+ if (vmSelect.Stores.Count > 0)
+ vm.SelectExistingOfferingModal = vmSelect;
+ }
+
+ string GetLabel(PlanData p) => canLogin.Contains(p.Id) ? $"{p.Name} (can-access)" : p.Name;
+ var storeId = HttpContext.GetCurrentStoreId();
+ vm.ActivateModal = new ActivateMonetizationModelViewModel(storeId, stores);
+ vm.MigrateUsersModal = new MigrateUsersModalViewModel()
+ {
+ AvailablePlans = activePlans.OrderBy(p => p.Name).Select(p => new SelectListItem(GetLabel(p), p.Id)).ToList(),
+ SelectedPlanId = vm.DefaultPlan?.Id ?? ""
+ };
+ return View(vm);
+ }
+
+ [HttpPost]
+ public async Task<IActionResult> Monetization(MonetizationViewModel vm, string command)
+ {
+ if (command == "activate-monetization" && vm.ActivateModal is {} activateModal)
+ {
+ var selectedStore = vm.ActivateModal?.SelectedStoreId ?? "";
+ var store = await storeRepo.FindStore(selectedStore, userManager.GetUserId(User) ?? "");
+ if (store is null)
+ {
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = "You need to select a store first",
+ Severity = StatusMessageModel.StatusSeverity.Error
+ });
+ return RedirectToAction(nameof(Monetization));
+ }
+
+ if (!ModelState.IsValid)
+ return await Monetization();
+ var (_, offeringId) = await appService.CreateOffering(selectedStore, "BTCPay Server Access");
+
+ var entitlements = CreateDefaultEntitlements(offeringId);
+ foreach (var e in entitlements.Values)
+ {
+ ctx.Entitlements.Add(e);
+ }
+
+ var currency = store.GetStoreBlob().DefaultCurrency;
+ var price = activateModal.StarterPlanCost;
+ price = Math.Round(price, currencyNameTable.GetNumberFormatInfo(currency)?.CurrencyDecimalDigits ?? 2);
+ PlanData starterPlan = new()
+ {
+ Name = "Starter Plan",
+ RecurringType = PlanData.RecurringInterval.Monthly,
+ TrialDays = activateModal.TrialDays,
+ Currency = currency,
+ Price = price,
+ OfferingId = offeringId,
+ };
+ ctx.Plans.Add(starterPlan);
+ ctx.PlanEntitlements.AddRange(
+ new[] { MonetizationEntitlements.CanAccess }
+ .Select(e => new PlanEntitlementData()
+ {
+ Plan = starterPlan,
+ Entitlement = entitlements[e],
+ }));
+ var serverBase = Request.GetRequestBaseUrl().ToString();
+ if (store.StoreWebsite != serverBase)
+ store.StoreWebsite = serverBase;
+ await ctx.SaveChangesAsync();
+
+ await UpdatePoliciesSettings(true);
+
+ var serverSettings = await settingsRepository.GetSettingAsync<ServerSettings>() ?? new();
+ if (serverSettings.BaseUrl != serverBase)
+ {
+ serverSettings.BaseUrl = serverBase;
+ await settingsRepository.UpdateSetting(serverSettings);
+ }
+
+ var defaultPlanId = starterPlan.Id;
+ List<EmailRuleData> emailRules =
+ [
+ new()
+ {
+ Trigger = "WH-" + WebhookSubscriptionEvent.PaymentReminder,
+ Subject = "Payment reminder for your subscription",
+ Body = EmailsPlugin.CreateEmail(
+ "In order to renew your subscription, please renew before expiration.",
+ "Go to Subscription portal", linkGenerator.UserManageBillingLink(Request.GetRequestBaseUrl())),
+ Condition = UIOfferingController.CreateOfferingCondition(offeringId)
+ },
+ new()
+ {
+ Trigger = "WH-" + WebhookSubscriptionEvent.SubscriberPhaseChanged,
+ Subject = "Your subscription has expired",
+ Body = EmailsPlugin.CreateEmail(
+ "Your access has expired. Please renew your subscription to continue using it.",
+ "Go to Subscription portal", linkGenerator.UserManageBillingLink(Request.GetRequestBaseUrl())),
+ Condition = UIOfferingController.CreateOfferingCondition(offeringId, SubscriberData.PhaseTypes.Expired)
+ }
+ ];
+
+ foreach (var rule in emailRules)
+ {
+ rule.StoreId = store.Id;
+ rule.OfferingId = offeringId;
+ rule.To = ["{Subscriber.Email}"];
+ ctx.EmailRules.Add(rule);
+ }
+
+ await ctx.SaveChangesAsync();
+
+ var migratedUsers = 0;
+ if (activateModal.MigrateExistingUsers)
+ {
+ migratedUsers = (await monetizationService.MigrateUsers(offeringId, starterPlan.Id)).Length;
+ }
+
+ if (await ctx.Offerings.GetOfferingData(offeringId) is { } off)
+ {
+ var settings = await settingsRepository.GetSettingAsync<MonetizationSettings>() ?? new();
+ settings.OfferingId = offeringId;
+ settings.DefaultPlanId = defaultPlanId;
+ await settingsRepository.UpdateSetting(settings);
+
+ var migratedUserText = migratedUsers > 0 ? StringLocalizer["({0} migrated users)", migratedUsers].Value : "";
+ var offeringUrl = linkGenerator.OfferingLink(off.App.StoreDataId, off.Id, SubscriptionSection.Plans, Request.GetRequestBaseUrl());
+ TempData.SetStatusMessageModel(new()
+ {
+ LocalizedHtml = ViewLocalizer[
+ "Monetization activated, users who register to your server from now will be subscriber of <a class=\"alert-link\" href=\"{0}\">this offering</a>.{1}",
+ offeringUrl, migratedUserText],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ }
+ else if (command == "change-offering")
+ {
+ var settings = new MonetizationSettings()
+ {
+ OfferingId = vm.SelectExistingOfferingModal?.SelectedOfferingId,
+ DefaultPlanId = vm.SelectExistingOfferingModal?.SelectedPlanId
+ };
+ if (await ctx.GetOfferingAndPlan(settings) is { } v)
+ {
+ await settingsRepository.UpdateSetting(settings);
+ await UpdatePoliciesSettings(true);
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = StringLocalizer["Monetization order updated to offering {0} with default plan {1}.", v.Offering.App.Name, v.Plan.Name],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ }
+ else if (command == "migrate-users")
+ {
+ var settings = await settingsRepository.GetSettingAsync<MonetizationSettings>();
+ if (await ctx.GetOfferingAndPlan(settings) is { } v)
+ {
+ var count = (await monetizationService.MigrateUsers(v.Offering.Id, vm.MigrateUsersModal?.SelectedPlanId)).Length;
+ // Should we fire NewSubscriber event?
+ // Given this is a one time operation maybe not...
+ // This means the email rules won't be triggered
+ // Anyway, if we do, we should do it on a separate task to not block this method.
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = StringLocalizer["{0} users migrated to the plan '{1}'.", count, v.Plan.Name],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ }
+ else if (command == "demonetize")
+ {
+ var settings = await settingsRepository.GetSettingAsync<MonetizationSettings>() ?? new();
+ settings.DefaultPlanId = null;
+ settings.OfferingId = null;
+ await settingsRepository.UpdateSetting(settings);
+ await UpdatePoliciesSettings(false);
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = StringLocalizer["Monetization deactivated, users who register to your server from now will not be subscriber of any offering."],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ else if (command == "copy-server-email-settings")
+ {
+ var settings = await settingsRepository.GetSettingAsync<MonetizationSettings>();
+ var offeringAndPlan = await ctx.GetOfferingAndPlan(settings);
+ if (offeringAndPlan is { Offering: { } offering } &&
+ await storeRepo.FindStore(offering.App.StoreDataId, userManager.GetUserId(User) ?? "") is { } store)
+ {
+ var storeBlob = store.GetStoreBlob();
+ var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
+ if (policies.DisableStoresToUseServerEmailSettings)
+ {
+ var serverSettings = await settingsRepository.GetSettingAsync<EmailSettings>() ?? new();
+ storeBlob.EmailSettings = serverSettings;
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = StringLocalizer["Store emails settings copied from server settings"],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ else
+ {
+ storeBlob.EmailSettings = null;
+ TempData.SetStatusMessageModel(new()
+ {
+ Message = StringLocalizer["Store emails settings are now using the server's SMTP settings."],
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+
+ store.SetStoreBlob(storeBlob);
+ await storeRepo.UpdateStoreBlob(store);
+ }
+ }
+
+ return RedirectToAction(nameof(Monetization));
+ }
+
+ private async Task UpdatePoliciesSettings(bool monetization)
+ {
+ var registrationLink = Url.Action(action: nameof(UIUserMonetizationController.NewUser), controller: "UIUserMonetization");
+ var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
+ policies.RequiresConfirmedEmail = true;
+ if (monetization && policies.RegisterPageRedirect is null)
+ policies.RegisterPageRedirect = registrationLink;
+ if (!monetization && policies.RegisterPageRedirect == registrationLink)
+ policies.RegisterPageRedirect = null;
+ await settingsRepository.UpdateSetting(policies);
+ }
+
+ private Dictionary<string, EntitlementData> CreateDefaultEntitlements(string offeringId)
+ {
+ var entitlements = new[]
+ {
+ (MonetizationEntitlements.CanAccess, StringLocalizer["Can access BTCPay Server"].Value),
+ }.Select(e => new EntitlementData()
+ {
+ CustomId = e.Item1,
+ Description = e.Item2,
+ OfferingId = offeringId,
+ }).ToDictionary(e => e.CustomId, e => e);
+ return entitlements;
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/Controllers/UIUserMonetizationController.cs b/BTCPayServer/Plugins/Monetization/Controllers/UIUserMonetizationController.cs
new file mode 100644
index 0000000..91b1e1b
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Controllers/UIUserMonetizationController.cs
@@ -0,0 +1,104 @@
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.Plugins.Subscriptions;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Plugins.Monetization.Controllers;
+
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewProfile)]
+[Area(MonetizationPlugin.Area)]
+public class UIUserMonetizationController(
+ ApplicationDbContext ctx,
+ MonetizationSettings settings,
+ PoliciesSettings policies,
+ UserManager<ApplicationUser> userManager,
+ LinkGenerator linkGenerator
+ ) : Controller
+{
+ [HttpGet("~/monetization/new-user")]
+ [AllowAnonymous]
+ public async Task<IActionResult> NewUser()
+ {
+ if (settings is not { OfferingId: { } offeringId, DefaultPlanId: { } defaultPlanId })
+ return NotFound();
+ if (!policies.EnableRegistration)
+ return NotFound();
+ var plan = await ctx.Plans.GetPlanFromId(defaultPlanId, offeringId);
+ if (plan is null)
+ return NotFound();
+ var checkout = new PlanCheckoutData()
+ {
+ PlanId = plan.Id,
+ Plan = plan,
+ NewSubscriber = true,
+ IsTrial = plan.TrialDays > 0,
+ BaseUrl = Request.GetRequestBaseUrl()
+ };
+ ctx.PlanCheckouts.Add(checkout);
+ await ctx.SaveChangesAsync();
+ return Redirect(linkGenerator.PlanCheckout(checkout.Id, checkout.BaseUrl));
+ }
+
+ [HttpGet("~/account/billing")]
+ [Authorize(AuthenticationSchemes = $"{AuthenticationSchemes.LimitedLogin},{AuthenticationSchemes.Cookie}")]
+ public async Task<IActionResult> ManageBilling(bool fastRedirect = false)
+ {
+ if (settings.OfferingId is not { } offeringId)
+ return NotFound();
+ var userId = userManager.GetUserId(User);
+ var sub = await ctx.Subscribers.GetBySelector(offeringId, CustomerSelector.ByIdentity(SubscriberDataExtensions.IdentityType, userId));
+ if (sub is null)
+ return NotFound();
+
+ // With fast redirect, if we detect there is no can-access and we have only one plan change,
+ // we go straight to a hard migration. (Which reimburses the user for the unused part of his current plan)
+ if (fastRedirect)
+ {
+ if (sub is { PlanId: { } planId } &&
+ !await ctx.Plans.HasEntitlements(planId, MonetizationEntitlements.CanAccess))
+ {
+ var planChanges =
+ await ctx.PlanChanges
+ .Include(o => o.PlanChange)
+ .Where(pc => pc.PlanId == planId)
+ .ToArrayAsync();
+ if (planChanges.Length == 1)
+ {
+ var upgradePlan = planChanges[0].PlanChange;
+ var checkout = new PlanCheckoutData(sub, upgradePlan)
+ {
+ BaseUrl = Request.GetRequestBaseUrl(),
+ IsTrial = upgradePlan.TrialDays > 0,
+ OnPay = PlanCheckoutData.OnPayBehavior.HardMigration
+ };
+ ctx.PlanCheckouts.Add(checkout);
+ await ctx.SaveChangesAsync();
+ return Redirect(linkGenerator.PlanCheckout(checkout.Id, Request.GetRequestBaseUrl()));
+ }
+ }
+ }
+ return await CreatePortalSession(sub);
+ }
+
+ private async Task<IActionResult> CreatePortalSession(SubscriberData sub)
+ {
+ var portal = new PortalSessionData()
+ {
+ BaseUrl = Request.GetRequestBaseUrl(),
+ Subscriber = sub
+ };
+ ctx.PortalSessions.Add(portal);
+ await ctx.SaveChangesAsync();
+ return Redirect(linkGenerator.SubscriberPortalLink(portal.Id, portal.BaseUrl));
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/DataExtensions.cs b/BTCPayServer/Plugins/Monetization/DataExtensions.cs
new file mode 100644
index 0000000..aea7fca
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/DataExtensions.cs
@@ -0,0 +1,31 @@
+#nullable enable
+
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+
+namespace BTCPayServer.Plugins.Monetization;
+
+public static class SubscriberDataExtensions
+{
+ public const string IdentityType = "ApplicationUserId";
+ public static string? GetApplicationUserId(this SubscriberData subscriber)
+ => subscriber.Customer.GetContact(IdentityType);
+}
+
+public static class DataExtensions
+{
+ public static async Task<(OfferingData Offering, PlanData Plan)?> GetOfferingAndPlan(this ApplicationDbContext ctx, MonetizationSettings? settings)
+ {
+ if (settings is null)
+ return null;
+ var offering = await ctx.Offerings.GetOfferingData(settings.OfferingId ?? "");
+ if (offering is null)
+ return null;
+ var plan = await ctx.Plans.GetPlanFromId(settings.DefaultPlanId ?? "");
+ if (plan is null || plan.OfferingId != offering.Id)
+ return null;
+ return (offering, plan);
+ }
+}
+
diff --git a/BTCPayServer/Plugins/Monetization/LinkGenerator.Monetization.cs b/BTCPayServer/Plugins/Monetization/LinkGenerator.Monetization.cs
new file mode 100644
index 0000000..64d067b
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/LinkGenerator.Monetization.cs
@@ -0,0 +1,19 @@
+#nullable enable
+using BTCPayServer.Abstractions;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Controllers;
+using BTCPayServer.Plugins.Monetization;
+using BTCPayServer.Plugins.Monetization.Controllers;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+public static class MonetizationUrlHelperExtensions
+{
+ public static string UserManageBillingLink(this LinkGenerator linkGenerator, RequestBaseUrl baseUrl, bool fastRedirect = false)
+ => linkGenerator.GetUriByAction(
+ nameof(UIUserMonetizationController.ManageBilling),
+ "UIUserMonetization",
+ new { area = MonetizationPlugin.Area, fastRedirect },
+ baseUrl);
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationEmailTransformer.cs b/BTCPayServer/Plugins/Monetization/MonetizationEmailTransformer.cs
new file mode 100644
index 0000000..466b057
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationEmailTransformer.cs
@@ -0,0 +1,38 @@
+using BTCPayServer.Abstractions;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Plugins.Monetization.Controllers;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Monetization;
+
+public class MonetizationEmailTriggerTransformer(
+ ISettingsAccessor<MonetizationSettings> monetization,
+ LinkGenerator linkGenerator,
+ ISettingsAccessor<ServerSettings> serverSettings) : IEmailTriggerViewModelTransformer, IEmailTriggerEventTransformer
+{
+ public void Transform(EmailTriggerViewModel viewModel)
+ {
+ if (serverSettings.Settings.BaseUrl != null && monetization.Settings.IsSetup() && !viewModel.ServerTrigger)
+ {
+ viewModel.PlaceHolders.Add(new("{Links.BillingPortalUrl}", BillingPortalUrlDoc));
+ }
+ }
+ public const string BillingPortalUrlDoc = "The billing portal URL of the logged account";
+ public static string[] TranslatedStrings => new[] { BillingPortalUrlDoc };
+
+ public void Transform(IEmailTriggerEventTransformer.Context context)
+ {
+ if (serverSettings.Settings.BaseUrl is string baseUrl
+ && context.Store is not null
+ && RequestBaseUrl.TryFromUrl(baseUrl, out var r)
+ && monetization.Settings.IsSetup())
+ {
+ var userObj = (JObject)(context.TriggerEvent.Model["Links"] ??= new JObject());
+ userObj["BillingPortalUrl"] = linkGenerator.UserManageBillingLink(r);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationEntitlements.cs b/BTCPayServer/Plugins/Monetization/MonetizationEntitlements.cs
new file mode 100644
index 0000000..1bd5885
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationEntitlements.cs
@@ -0,0 +1,6 @@
+namespace BTCPayServer.Plugins.Monetization;
+
+public class MonetizationEntitlements
+{
+ public const string CanAccess = "can-access";
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs b/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs
new file mode 100644
index 0000000..a6865ca
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationHostedService.cs
@@ -0,0 +1,339 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.Events;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Logging;
+using BTCPayServer.Plugins.Subscriptions;
+using BTCPayServer.Services;
+using Dapper;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using NBitcoin;
+
+namespace BTCPayServer.Plugins.Monetization;
+
+public class MonetizationHostedService(
+ ApplicationDbContextFactory dbContextFactory,
+ EventAggregator eventAggregator,
+ SettingsRepository settingsRepository,
+ UserService userService,
+ BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers,
+ ISettingsAccessor<MonetizationSettings> monetizationSettingsAccessor,
+ IServiceScopeFactory serviceScopeFactory,
+ Logs logger) : EventHostedServiceBase(eventAggregator, logger)
+{
+ public class MonetizationLockoutUpdated((string UserId, bool LockoutEnabled)[] updated)
+ {
+ public (string UserId, bool LockoutEnabled)[] Updated { get; } = updated;
+ }
+
+ protected override void SubscribeToEvents()
+ {
+ this.Subscribe<SubscriptionEvent.NewSubscriber>();
+ this.Subscribe<SubscriptionEvent.SubscriberActivated>();
+ this.Subscribe<SubscriptionEvent.SubscriberDisabled>();
+ this.Subscribe<SubscriptionEvent.PlanUpdated>();
+ this.Subscribe<SubscriptionEvent.PlanStarted>();
+ this.SubscribeAny<UserEvent.Registered>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ if (evt is SubscriptionEvent.SubscriberEvent se && !IsMonetization(se))
+ return;
+ using var scope = serviceScopeFactory.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ if (evt is SubscriptionEvent.NewSubscriber newSub && newSub.Subscriber.GetApplicationUserId() is null)
+ {
+ var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
+ var email = newSub.Subscriber.Customer.Email.Get();
+ var user = new ApplicationUser
+ {
+ UserName = email,
+ Email = email,
+ RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
+ RequiresApproval = policies.RequiresUserApproval,
+ Created = DateTimeOffset.UtcNow,
+ Approved = false
+ };
+ var created = await userManager.CreateAsync(user);
+ if (created.Succeeded)
+ {
+ await AttachUserIdToSubscriber(user.Id, newSub);
+ var callbackGenerator = scope.ServiceProvider.GetRequiredService<CallbackGenerator>();
+ callbackGenerator.BaseUrl = newSub.RequestBaseUrl;
+ EventAggregator.Publish(await UserEvent.Registered.Create(user, null, callbackGenerator));
+ }
+ else
+ {
+ var existing = await userManager.FindByEmailAsync(email ?? "");
+ if (existing is not null)
+ await AttachUserIdToSubscriber(existing.Id, newSub);
+ }
+ }
+
+ if (evt is SubscriptionEvent.SubscriberActivated or SubscriptionEvent.SubscriberDisabled)
+ {
+ await UpdateUserLockout((SubscriptionEvent.SubscriberEvent)evt, userManager, evt is SubscriptionEvent.SubscriberActivated);
+ }
+
+ if (evt is SubscriptionEvent.PlanStarted ps && ps.PreviousPlan.Id != ps.Subscriber.Plan.Id)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var canAccess = await ctx.Plans.HasEntitlements(ps.Subscriber.PlanId, MonetizationEntitlements.CanAccess);
+ await UpdateUserLockout(ps, userManager, canAccess);
+ }
+
+ if (evt is SubscriptionEvent.PlanUpdated pu)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ await UpdateUserLockoutStatus(ctx, pu.Plan);
+ }
+
+ if (evt is UserEvent.Registered reg && monetizationSettingsAccessor.Settings is
+ {
+ OfferingId: { } offeringId,
+ DefaultPlanId: { } defaultPlanId
+ })
+ {
+ if (await userService.IsAdminUser(reg.User))
+ return;
+ await using var ctx = dbContextFactory.CreateContext();
+ var userSub = await ctx.Subscribers.GetBySelector(offeringId, CustomerSelector.ByIdentity(SubscriberDataExtensions.IdentityType, reg.User.Id));
+ if (userSub is not null)
+ return;
+ var inserted = (await MigrateUsers(offeringId, defaultPlanId, OneUserQuery, parameters =>
+ {
+ parameters.Add("userId", reg.User.Id);
+ parameters.Add("email", reg.User.Email);
+ parameters.Add("customerId", CustomerData.GenerateId());
+ }));
+ if (inserted.Length == 1)
+ {
+ var s = await ctx.Subscribers.GetByCustomerId(inserted[0].CustomerId, offeringId);
+ if (s is not null)
+ EventAggregator.Publish(new SubscriptionEvent.NewSubscriber(s, reg.RequestBaseUrl));
+ }
+ }
+ }
+
+ private async Task UpdateUserLockout(SubscriptionEvent.SubscriberEvent evt, UserManager<ApplicationUser> userManager, bool activated)
+ {
+ var userId = evt.Subscriber.GetApplicationUserId();
+ var user = await userManager.FindByIdAsync(userId ?? "");
+ if (user is not null &&
+ await userService.SetDisabled(user.Id, !activated) is not UserService.SetDisabledResult.Error)
+ EventAggregator.Publish(new MonetizationLockoutUpdated([(user.Id, !activated)]));
+ }
+
+ private async Task AttachUserIdToSubscriber(string id, SubscriptionEvent.NewSubscriber newSub)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ await ctx.Database.GetDbConnection()
+ .ExecuteAsync("""
+ INSERT INTO customers_identities (customer_id, type, value) VALUES (@customerId, @identityType, @userId)
+ ON CONFLICT (customer_id, type) DO NOTHING;
+ """, new { customerId = newSub.Subscriber.CustomerId, userId = id, identityType = SubscriberDataExtensions.IdentityType });
+ }
+
+ private bool IsMonetization(SubscriptionEvent.SubscriberEvent se)
+ => monetizationSettingsAccessor.Settings.OfferingId == se.Subscriber.OfferingId;
+
+
+ private const string NonAdminUserQuery = """
+ WITH subs AS (
+ SELECT s.id, ci.value user_id
+ FROM subs_subscribers s
+ JOIN customers_identities ci ON ci.customer_id = s.customer_id
+ WHERE s.offering_id = @offeringId AND ci.type = @applicationUserId
+ ),
+ non_admin_users AS (
+ SELECT DISTINCT u."Id" user_id, u."Email" email
+ FROM "AspNetUsers" u
+ 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
+ ),
+ users_to_migrate AS (
+ SELECT u.user_id, u.email, NULL as customer_id
+ FROM non_admin_users u
+ LEFT JOIN subs s ON u.user_id = s.user_id
+ WHERE s.user_id IS NULL
+ )
+ """;
+
+ private const string OneUserQuery = """
+ WITH users_to_migrate AS (
+ SELECT @userId as user_id, @email as email, @customerId as customer_id
+ )
+ """;
+
+ public async Task<(string CustomerId, string UserId)[]> MigrateUsers(string? offeringId, string? planId, string usersQuery = NonAdminUserQuery, Action<DynamicParameters>? addParameters = null)
+ {
+ if (offeringId is null || planId is null)
+ return Array.Empty<(string CustomerId, string UserId)>();
+ await using var ctx = dbContextFactory.CreateContext();
+ if (await ctx.Offerings.GetOfferingData(offeringId) is not { } offering ||
+ offering.Plans
+ .Where(p => p.Status == PlanData.PlanStatus.Active)
+ .FirstOrDefault(p => p.Id == planId) is not { } plan)
+ return Array.Empty<(string CustomerId, string UserId)>();
+
+ var hasTrial = plan.TrialDays > 0;
+ var dummy = new SubscriberData();
+ dummy.Plan = plan;
+ if (hasTrial)
+ dummy.StartNextPlan(DateTimeOffset.UtcNow, hasTrial);
+
+ DynamicParameters parameters = new(new
+ {
+ offeringId,
+ planId,
+ storeId = offering.App.StoreDataId,
+ salt = RandomUtils.GetUInt256().ToString(),
+ planStarted = dummy.PlanStarted,
+ trialEnd = dummy.TrialEnd,
+ periodEnd = dummy.PeriodEnd,
+ gracePeriodEnd = dummy.GracePeriodEnd,
+ paidAmount = dummy.PaidAmount,
+ phase = (hasTrial ? SubscriberData.PhaseTypes.Trial : SubscriberData.PhaseTypes.Expired).ToString(),
+ active = hasTrial,
+ applicationUserId = SubscriberDataExtensions.IdentityType,
+ adminRole = Roles.ServerAdmin
+ });
+ addParameters?.Invoke(parameters);
+ var userIds = (await ctx.Database.GetDbConnection()
+ .QueryAsync<(string CustomerId, string UserId)>($"""
+ {usersQuery},
+ customers_already_created AS (
+ SELECT c.id AS customer_id, um.email, um.user_id FROM users_to_migrate um
+ JOIN customers_identities ci ON ci.type = 'Email' AND ci.value = um.email
+ JOIN customers c ON c.id = ci.customer_id AND c.store_id = @storeId
+ ),
+ customers_to_create AS (
+ SELECT COALESCE(um.customer_id, 'cust_' || translate(encode(decode(md5(um.email || @salt), 'hex'), 'base64'), '/=+-', '')) AS customer_id, um.email, um.user_id FROM users_to_migrate um
+ LEFT JOIN customers_already_created cac ON um.user_id = cac.user_id
+ WHERE cac.user_id IS NULL
+ ),
+ customers_all AS (
+ SELECT * FROM customers_to_create UNION ALL SELECT * FROM customers_already_created
+ ),
+ inserted_customers AS (
+ INSERT INTO customers (id, store_id)
+ SELECT customer_id, @storeId
+ FROM customers_to_create cc
+ RETURNING id, store_id
+ ),
+ inserted_email_identities AS (
+ INSERT INTO customers_identities (customer_id, type, value)
+ SELECT customer_id, 'Email', email FROM customers_to_create
+ RETURNING customer_id, type, value
+ ),
+ inserted_userId_identities AS (
+ INSERT INTO customers_identities (customer_id, type, value)
+ SELECT customer_id, @applicationUserId, user_id FROM customers_all
+ ON CONFLICT DO NOTHING
+ RETURNING customer_id, type, value
+ ),
+ inserted_subs AS (
+ INSERT INTO subs_subscribers (
+ customer_id,
+ offering_id,
+ plan_id,
+ plan_started,
+ trial_end,
+ period_end,
+ grace_period_end,
+ paid_amount,
+ phase,
+ active,
+ optimistic_activation)
+ SELECT um.customer_id,
+ @offeringId,
+ @planId,
+ @planStarted,
+ @trialEnd,
+ @periodEnd,
+ @gracePeriodEnd,
+ @paidAmount,
+ @phase,
+ @active,
+ false
+ FROM customers_all um
+ ON CONFLICT (customer_id, offering_id) DO NOTHING
+ RETURNING customer_id
+ )
+ SELECT i.customer_id, COALESCE(u.value, ci.value) user_id
+ FROM inserted_subs i
+ LEFT JOIN customers_identities ci ON ci.type = @applicationUserId AND ci.customer_id = i.customer_id
+ LEFT JOIN inserted_userId_identities u ON u.customer_id = i.customer_id;
+ """, parameters)).ToArray();
+ if (userIds.Length != 0)
+ {
+ await SubscriptionHostedService.UpdatePlanStats(ctx, plan.Id);
+ await UpdateUserLockoutStatus(ctx, plan, userIds.Select(c => c.UserId).ToArray());
+ // We expect the caller to call NewSubscriberEvent.
+ }
+
+ return userIds;
+ }
+
+ private async Task UpdateUserLockoutStatus(ApplicationDbContext ctx, PlanData plan, string[]? userIds = null)
+ {
+ if (userIds is null)
+ userIds = await GetUserIdsInPlan(ctx, plan);
+ if (userIds.Length == 0)
+ return;
+ var canAccess = await ctx.Plans.HasEntitlements(plan.Id, MonetizationEntitlements.CanAccess);
+ var updated = (await ctx.Database.GetDbConnection()
+ .QueryAsync<(string UserId, bool LockoutEnabled)>("""
+ WITH
+ subs AS (
+ SELECT user_id AS user_id,
+ NOT (ss.active AND @canAccess) AS lockout_enabled,
+ CASE WHEN (ss.active AND @canAccess) THEN NULL ELSE 'infinity'::timestamptz END lockout_end
+ FROM unnest(@userIds) AS user_id
+ JOIN customers_identities ci ON ci.type = @applicationUserId AND ci.value = user_id
+ JOIN subs_subscribers ss ON ss.customer_id = ci.customer_id
+ JOIN "AspNetUsers" ON "AspNetUsers"."Id" = user_id
+ WHERE ss.plan_id = @planId
+ )
+ UPDATE "AspNetUsers"
+ SET "LockoutEnabled" = subs.lockout_enabled,
+ "LockoutEnd" = subs.lockout_end,
+ "SecurityStamp" = translate(encode(decode(md5("Id" || @salt), 'hex'), 'base64'), '/=+-', '')
+ FROM subs WHERE "AspNetUsers"."Id" = subs.user_id AND ("AspNetUsers"."LockoutEnabled" IS DISTINCT FROM subs.lockout_enabled OR "AspNetUsers"."LockoutEnd" IS DISTINCT FROM subs.lockout_end)
+ RETURNING "Id", "LockoutEnabled";
+ """,
+ new{
+ userIds,
+ planId = plan.Id,
+ salt = RandomUtils.GetUInt256().ToString(),
+ applicationUserId = SubscriberDataExtensions.IdentityType,
+ canAccess
+ })).ToArray();
+ foreach (var update in updated)
+ if (update.LockoutEnabled)
+ disabledUsers.Add(update.UserId);
+ else
+ disabledUsers.Remove(update.UserId);
+ EventAggregator.Publish(new MonetizationLockoutUpdated(updated));
+ }
+
+ private static async Task<string[]> GetUserIdsInPlan(ApplicationDbContext ctx, PlanData plan)
+ => (await ctx.Database.GetDbConnection()
+ .QueryAsync<string>("""
+ SELECT ci.value
+ FROM subs_subscribers s
+ JOIN customers_identities ci ON ci.customer_id = s.customer_id
+ WHERE s.offering_id=@offeringId AND s.plan_id = @planId AND ci.type = @applicationUserId
+ """, new { planId = plan.Id, offeringId = plan.OfferingId, applicationUserId = SubscriberDataExtensions.IdentityType }))
+ .ToArray();
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs b/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs
new file mode 100644
index 0000000..6d808c5
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationLoginExtension.cs
@@ -0,0 +1,66 @@
+#nullable enable
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.Plugins.Subscriptions;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Plugins.Monetization;
+
+public class MonetizationLoginExtension(
+ ISettingsAccessor<MonetizationSettings> settings,
+ ApplicationDbContextFactory dbContextFactory,
+ LinkGenerator linkGenerator) : UserService.LoginExtension
+{
+ public override async Task Check(UserService.CanLoginContext context)
+ {
+ if (settings.Settings is { OfferingId: { } offeringId })
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, CustomerSelector.ByIdentity(SubscriberDataExtensions.IdentityType, context.User.Id));
+ if (subscriber is null)
+ return;
+
+ // The subscriber is not active and suspended => Show the suspension error
+ if (subscriber is { IsActive: false, IsSuspended: true })
+ {
+ var message = subscriber.SuspensionReason is {} reason ? new(reason, reason) : context.StringLocalizer["Please contact support."];
+ context.Failures.Add(new (context.StringLocalizer["Your subscription is suspended. {0}", message]));
+ RedirectToManageBilling(context);
+ return;
+ }
+
+ // The subscriber is in a plan without an access feature
+ if (subscriber is { PlanId: { } planId } &&
+ !await ctx.Plans.HasEntitlements(planId, MonetizationEntitlements.CanAccess))
+ {
+ context.Failures.Add(new (context.StringLocalizer["Your plan does not allow you to log in."]));
+ if (await CanChangePlan(ctx, planId))
+ RedirectToManageBilling(context);
+ }
+
+ // The subscriber is not active and not suspended => Redirect to subscriber portal
+ if (subscriber is { IsActive: false, IsSuspended: false })
+ {
+ context.Failures.Add(new (context.StringLocalizer["Your subscription is not active."]));
+ RedirectToManageBilling(context);
+ }
+ }
+ }
+
+ private static Task<bool> CanChangePlan(ApplicationDbContext ctx, string planId)
+ => ctx.PlanChanges
+ .Where(p => p.PlanId == planId)
+ .AnyAsync();
+
+ private void RedirectToManageBilling(UserService.CanLoginContext context)
+ {
+ if (context.BaseUrl is not null)
+ context.FailedRedirectUrl = linkGenerator.UserManageBillingLink(context.BaseUrl, true);
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationPlugin.cs b/BTCPayServer/Plugins/Monetization/MonetizationPlugin.cs
new file mode 100644
index 0000000..d374ea1
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationPlugin.cs
@@ -0,0 +1,30 @@
+#nullable enable
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace BTCPayServer.Plugins.Monetization;
+
+public class MonetizationPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Monetization";
+ public override string Identifier => "BTCPayServer.Plugins.Monetization";
+ public override string Name => "Monetization";
+ public override string Description => "Manage monetization of your server.";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddUIExtension("server-nav", "/Plugins/Monetization/Views/NavExtension.cshtml");
+ services.AddUIExtension("user-nav", "/Plugins/Monetization/Views/UserNavExtension.cshtml");
+ services.AddSingleton<MonetizationHostedService>();
+ services.AddSingleton<IHostedService, MonetizationHostedService>(o => o.GetRequiredService<MonetizationHostedService>());
+ services.AddSettingsAccessor<MonetizationSettings>();
+ services.AddSingleton<UserService.LoginExtension, MonetizationLoginExtension>();
+
+ services.AddSingleton<IEmailTriggerViewModelTransformer, MonetizationEmailTriggerTransformer>();
+ services.AddSingleton<IEmailTriggerEventTransformer, MonetizationEmailTriggerTransformer>();
+ services.AddDefaultTranslations(MonetizationEmailTriggerTransformer.TranslatedStrings);
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs b/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs
new file mode 100644
index 0000000..275c0a1
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/MonetizationSettings.cs
@@ -0,0 +1,9 @@
+namespace BTCPayServer.Plugins.Monetization;
+
+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/Plugins/Monetization/Views/ActivateMonetizationModal.cshtml b/BTCPayServer/Plugins/Monetization/Views/ActivateMonetizationModal.cshtml
new file mode 100644
index 0000000..9c96759
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/ActivateMonetizationModal.cshtml
@@ -0,0 +1,57 @@
+@model ActivateMonetizationModelViewModel
+
+<div class="modal fade" id="activateMonetization" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title" text-translate="true">Activate Monetization</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <form method="post">
+ <div class="modal-body">
+ <p text-translate="true">Turning on monetization creates a paid plan for your server.
+ All newly registered users will be added to this plan.
+ You may also migrate your existing users.
+ Only users with an active subscription with access feature can log in and use your server.</p>
+ <div>
+ <div class="form-group">
+ <label asp-for="SelectedStoreId" class="form-label" text-translate="true">Store</label>
+ <select asp-for="SelectedStoreId" class="form-control" asp-items="Model.Stores"></select>
+ </div>
+ <div class="d-flex gap-3">
+ <div class="form-group">
+ <label asp-for="StarterPlanCost" class="form-label" text-translate="true">Starter plan monthly cost</label>
+ <input inputmode="decimal" asp-for="StarterPlanCost" class="form-control"></input>
+ </div>
+ <div class="form-group">
+ <label asp-for="TrialDays" class="form-label"></label>
+ <div class="input-group">
+ <input asp-for="TrialDays" class="form-control" min="0" placeholder="7" />
+ <span class="input-group-text" text-translate="true">days</span>
+ </div>
+ <span asp-validation-for="TrialDays" class="text-danger"></span>
+ </div>
+ </div>
+ <div class="form-group">
+ <div class="form-check">
+ <input class="form-check-input" asp-for="MigrateExistingUsers" />
+ <label class="form-check-label" asp-for="MigrateExistingUsers"></label>
+ </div>
+ <p class="text-muted" text-translate="true">Those initial settings can be modified later.</p>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="submit"
+ class="btn btn-success modal-confirm"
+ name="command"
+ value="activate-monetization"
+ text-translate="true">Proceed
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModal.cshtml b/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModal.cshtml
new file mode 100644
index 0000000..5d935f1
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModal.cshtml
@@ -0,0 +1,30 @@
+@model MigrateUsersModalViewModel
+
+<div class="modal fade" id="migrateUsers" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title" text-translate="true">Migrate existing users</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <form method="post">
+ <div class="modal-body">
+ <p>@ViewLocalizer["By proceeding, all non-admin users will be migrated to the selected plan. If the plan does not include <b>can-access</b> entitlement, the user accounts will be disabled."]</p>
+ <div class="form-group">
+ <label asp-for="SelectedPlanId" class="form-label" text-translate="true">Select plan</label>
+ <select class="form-select" asp-items="Model.AvailablePlans" asp-for="SelectedPlanId"></select>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="submit"
+ class="btn btn-success modal-confirm"
+ name="command"
+ value="migrate-users"
+ text-translate="true">Proceed</button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModalViewModel.cs b/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModalViewModel.cs
new file mode 100644
index 0000000..3541c55
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/MigrateUsersModalViewModel.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+using System.Linq;
+using BTCPayServer.Data.Subscriptions;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BTCPayServer.Plugins.Monetization.Views;
+
+public class MigrateUsersModalViewModel
+{
+ public string SelectedPlanId { get; set; }
+ public List<SelectListItem> AvailablePlans { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Monetization/Views/Monetization.cshtml b/BTCPayServer/Plugins/Monetization/Views/Monetization.cshtml
new file mode 100644
index 0000000..f0c9b63
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/Monetization.cshtml
@@ -0,0 +1,230 @@
+@using BTCPayServer.Plugins.Subscriptions
+@inject Security.ContentSecurityPolicies Csp
+
+@model MonetizationViewModel
+
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(MonetizationPlugin), StringLocalizer["Monetization"])
+ .SetCategory(WellKnownCategories.Server));
+ Csp.UnsafeEval();
+}
+
+@functions {
+
+ async Task CreateNewOffering(bool primary)
+ {
+ <a id="@(primary ? "page-primary" : null)"
+ class="btn btn-primary"
+ data-bs-toggle="modal"
+ data-bs-target="#activateMonetization"
+ data-is-new="true"
+ role="button" text-translate="true">
+ Create a new offering
+ </a>
+ }
+
+ async Task PrintStatus(bool checkmark)
+ {
+ if (checkmark)
+ {
+ <vc:icon symbol="checkmark" css-class="text-success mr-1"></vc:icon>
+ }
+ else
+ {
+ <vc:icon symbol="cross" css-class="text-danger mr-1"></vc:icon>
+ }
+ }
+
+}
+
+<div class="sticky-header">
+ <h2 class="my-1">@ViewData["Title"]</h2>
+ @if (Model.DefaultPlan is null)
+ {
+ await CreateNewOffering(true);
+ }
+ else
+ {
+ <a id="go-to-offering"
+ asp-area="@SubscriptionsPlugin.Area"
+ asp-controller="UIOffering"
+ asp-action="Offering"
+ asp-route-storeId="@Model.DefaultPlan.Offering.App.StoreDataId"
+ asp-route-offeringId="@Model.Settings.OfferingId"
+ asp-route-section="Plans"
+ class="btn btn-secondary"
+ role="button">
+ Go to offering
+ </a>
+ }
+</div>
+<partial name="_StatusMessage" />
+
+
+<p class="mb-0" text-translate="true">Monetization allows you to get paid for sharing your BTCPay Server instance with other users.</p>
+
+<div class="col-xxl-constrain col-xl-8">
+ <div class="accordion" id="accordion">
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="activate-header">
+ <button class="accordion-button @(Model.Step == MonetizationViewModel.InstallStatus.SetOffering ? "" : "collapsed")"
+ type="button" data-bs-toggle="collapse" data-bs-target="#activate-collapse"
+ aria-expanded="@(Model.Step == MonetizationViewModel.InstallStatus.SetOffering)" aria-controls="activate-collapse">
+ <h5 class="d-flex content-center gap-3">
+ @{ await PrintStatus(Model.Step != MonetizationViewModel.InstallStatus.SetOffering); }
+ <span>Set up the offering</span>
+ </h5>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="activate-collapse" class="accordion-collapse collapse @(Model.Step == MonetizationViewModel.InstallStatus.SetOffering ? "show" : "")"
+ aria-labelledby="activate-header" data-bs-parent="#accordion">
+ <div class="accordion-body">
+ @if (Model is { Offering: not null, DefaultPlan: not null })
+ {
+ <p>The offering has been set to <b>@Model.Offering.App.Name</b> with default plan <b>@Model.DefaultPlan.Name</b>.</p>
+ <p>Click
+ <a id="go-to-offering"
+ asp-area="@SubscriptionsPlugin.Area"
+ asp-controller="UIOffering"
+ asp-action="Offering"
+ asp-route-storeId="@Model.DefaultPlan.Offering.App.StoreDataId"
+ asp-route-offeringId="@Model.Offering.Id"
+ asp-route-section="Plans">here</a> to go to the offering page.</p>
+
+ <div class="d-flex gap-3">
+ <a id="demonetize-button"
+ class="btn btn-outline-danger" role="button"
+ data-bs-toggle="modal" data-bs-target="#ConfirmModal" text-translate="true">Demonetize</a>
+ <a id="migrate-users-button"
+ class="btn btn-secondary"
+ role="button"
+ data-bs-toggle="modal"
+ data-bs-target="#migrateUsers"
+ text-translate="true">
+ Migrate existing non-admin users
+ </a>
+ </div>
+ }
+ else
+ {
+ <p>Monetization will delegate access to your server to an offering.</p>
+ <div class="d-flex gap-3">
+ @{ await CreateNewOffering(false); }
+ @if (Model.SelectExistingOfferingModal is not null)
+ {
+ <a class="btn btn-secondary"
+ role="button"
+ data-bs-toggle="modal"
+ data-bs-target="#selectExistingOffering"
+ text-translate="true">
+ Select existing offering
+ </a>
+ }
+ </div>
+ }
+ </div>
+ </div>
+ </div>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="server-email-header">
+ <button class="accordion-button @(Model.Step == MonetizationViewModel.InstallStatus.ConfigureServerEmail ? "" : "collapsed")"
+ type="button" data-bs-toggle="collapse" data-bs-target="#server-email-collapse"
+ aria-expanded="@(Model.Step == MonetizationViewModel.InstallStatus.ConfigureServerEmail)" aria-controls="server-email-collapse">
+ <h5 class="d-flex content-center gap-3">
+ @{ await PrintStatus(Model.EmailServerConfigured); }
+ <span>Configure server email settings</span>
+ </h5>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="server-email-collapse"
+ class="accordion-collapse collapse @(Model.Step == MonetizationViewModel.InstallStatus.ConfigureServerEmail ? "show" : "")"
+ aria-labelledby="server-email-header" data-bs-parent="#accordion">
+ <div class="accordion-body">
+ <p>When a new user registers without password or has been invited, an email will be sent using those settings.</p>
+ @if (Model.EmailServerConfigured)
+ {
+ <p>Server email settings are properly configured. Click <a asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail"
+ asp-action="ServerEmailSettings">here</a> to modify the configuration.</p>
+ }
+ else
+ {
+ <a class="btn btn-success" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings">Configure
+ server email</a>
+ }
+ </div>
+ </div>
+ </div>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="store-email-header">
+ <button class="accordion-button @(Model.Step == MonetizationViewModel.InstallStatus.ConfigureStoreEmail ? "" : "collapsed")"
+ type="button" data-bs-toggle="collapse" data-bs-target="#store-email-collapse"
+ aria-expanded="@(Model.Step == MonetizationViewModel.InstallStatus.ConfigureStoreEmail)" aria-controls="store-email-collapse">
+ <h5 class="d-flex content-center gap-3">
+ @{ await PrintStatus(Model.EmailStoreConfigured); }
+ <span>Configure store email settings</span>
+ </h5>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="store-email-collapse"
+ class="accordion-collapse collapse @(Model.Step == MonetizationViewModel.InstallStatus.ConfigureStoreEmail ? "show" : "")"
+ aria-labelledby="store-email-header" data-bs-parent="#accordion">
+ <div class="accordion-body">
+ <p>Events related to the subscription such as payment reminder or subscription state changes will be using the store email settings.</p>
+ @if (Model.Offering is not null)
+ {
+ @if (Model.EmailStoreConfigured)
+ {
+ <p>Store email settings are properly configured. Click <a asp-area="@EmailsPlugin.Area" asp-controller="UIStoresEmail"
+ asp-route-storeId="@Model.Offering.App.StoreDataId"
+ asp-action="StoreEmailSettings">here</a> to modify the configuration.</p>
+ }
+ else
+ {
+ <div class="d-flex gap-3">
+ <a class="btn btn-success" role="button" asp-area="@EmailsPlugin.Area" asp-controller="UIStoresEmail"
+ asp-route-storeId="@Model.Offering.App.StoreDataId" asp-action="StoreEmailSettings">Go to store email settings</a>
+ <form method="post">
+ <button type="submit" name="command" value="copy-server-email-settings" class="btn btn-secondary">Copy from server email
+ settings
+ </button>
+ </form>
+ </div>
+ }
+ }
+ else
+ {
+ <p>You need to setup the offering first.</p>
+ }
+ </div>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+@if (Model.Offering is not null)
+{
+ <form method="post">
+ <input type="hidden" name="command" value="demonetize" />
+ <partial name="_Confirm" model="@(new ConfirmModel()
+ {
+ Title = StringLocalizer["Demonetize"],
+ Description = StringLocalizer["By confirming, you will deactivate the monetization feature, user access will not be dependent on subscriptions anymore."],
+ Action = StringLocalizer["Demonetize"],
+ GenerateForm = false,
+ })" />
+ </form>
+}
+@if (Model.SelectExistingOfferingModal is not null)
+{
+ <partial name="SelectExistingOfferingModal" for="SelectExistingOfferingModal" />
+}
+<partial name="MigrateUsersModal" for="MigrateUsersModal" />
+<partial name="ActivateMonetizationModal" for="ActivateModal" />
+
+@section PageFootContent {
+ <script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
+}
diff --git a/BTCPayServer/Plugins/Monetization/Views/MonetizationViewModel.cs b/BTCPayServer/Plugins/Monetization/Views/MonetizationViewModel.cs
new file mode 100644
index 0000000..47a0073
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/MonetizationViewModel.cs
@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using JetBrains.Annotations;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BTCPayServer.Plugins.Monetization.Views;
+
+public class MonetizationViewModel
+{
+ public enum InstallStatus
+ {
+ SetOffering,
+ ConfigureServerEmail,
+ ConfigureStoreEmail,
+ Done
+ }
+
+ public bool EmailServerConfigured { get; set; }
+ public bool EmailStoreConfigured { get; set; }
+ public InstallStatus Step { get; set; }
+ public MonetizationSettings Settings { get; set; }
+
+ public PlanData DefaultPlan { get; set; }
+ public ActivateMonetizationModelViewModel ActivateModal { get; set; }
+ public MigrateUsersModalViewModel MigrateUsersModal { get; set; }
+ public OfferingData Offering { get; set; }
+ public SelectExistingOfferingModalViewModel SelectExistingOfferingModal { get; set; }
+}
+
+public class ActivateMonetizationModelViewModel
+{
+ public ActivateMonetizationModelViewModel()
+ {
+
+ }
+
+ public ActivateMonetizationModelViewModel(
+ string selectedStoreId,
+ IEnumerable<StoreData> stores) : this()
+ {
+ SelectedStoreId = selectedStoreId;
+ Stores = stores.Select(s => new SelectListItem(s.StoreName, s.Id)).ToList();
+ }
+ public string SelectedStoreId { get; set; }
+ public IEnumerable<SelectListItem> Stores { get; set; }
+
+ [Range(0.01, double.MaxValue)]
+ [DisplayFormat(DataFormatString = "{0:0.00####}", ApplyFormatInEditMode = true)]
+ public decimal StarterPlanCost { get; set; } = 10m;
+
+ [Display(Name = "Trial Period (days)")]
+ [Range(0, 3650)]
+ public int TrialDays { get; set; } = 7;
+
+ [Display(Name = "Migrate existing non-admin users")]
+ public bool MigrateExistingUsers { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Monetization/Views/NavExtension.cshtml b/BTCPayServer/Plugins/Monetization/Views/NavExtension.cshtml
new file mode 100644
index 0000000..05549d4
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/NavExtension.cshtml
@@ -0,0 +1,12 @@
+@using BTCPayServer.Client
+@using BTCPayServer.Views.Server
+@model BTCPayServer.Components.MainNav.MainNavViewModel
+
+<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
+ <a
+ asp-area="@MonetizationPlugin.Area"
+ asp-controller="UIServerMonetization"
+ asp-action="Monetization"
+ layout-menu-item="@nameof(MonetizationPlugin)"
+ text-translate="true">Monetization</a>
+</li>
diff --git a/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModal.cshtml b/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModal.cshtml
new file mode 100644
index 0000000..eef5f5f
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModal.cshtml
@@ -0,0 +1,87 @@
+@model SelectExistingOfferingModalViewModel
+
+<div class="modal fade" id="selectExistingOffering" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title" text-translate="true">Select an existing offering</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <form method="post" id="selectExistingOffering-form">
+ <div class="modal-body">
+ <p>@ViewLocalizer["Choose a different offering for monetization"]</p>
+ <div class="form-group">
+ <label class="form-label" text-translate="true">Select store</label>
+ <select name="@Html.NameFor(o =>o.SelectedStoreId)" class="form-select" v-model="selectedStoreId">
+ <option v-for="store in model.stores" :key="store.id" :value="store.id">{{ store.name }}</option>
+ </select>
+ </div>
+ <div class="form-group" v-if="selectedStore">
+ <label class="form-label" text-translate="true">Select offering</label>
+ <select name="@Html.NameFor(o =>o.SelectedOfferingId)" class="form-select" v-model="selectedOfferingId">
+ <option v-for="offering in selectedStore.offerings" :key="offering.id" :value="offering.id">{{ offering.name }}</option>
+ </select>
+ </div>
+ <div class="form-group" v-if="selectedOffering">
+ <label class="form-label" text-translate="true">Select default plan</label>
+ <select name="@Html.NameFor(o =>o.SelectedPlanId)" class="form-select" v-model="selectedPlanId">
+ <option v-for="plan in selectedOffering.plans" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
+ </select>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="submit"
+ class="btn btn-success modal-confirm"
+ name="command"
+ value="change-offering"
+ :disabled="!selectedPlanId"
+ text-translate="true">Proceed
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
+
+<script>
+ var model = @Safe.Json(Model);
+ document.addEventListener('DOMContentLoaded', function () {
+ var app = new Vue({
+ el: '#selectExistingOffering-form',
+ data() {
+ return {
+ model: model,
+ selectedStoreId: model.selectedStoreId,
+ selectedOfferingId: model.selectedOfferingId,
+ selectedPlanId: model.selectedPlanId,
+ };
+ },
+ computed: {
+ selectedStore() {
+ return this.selectedStoreId ? this.model.stores.find(o => o.id === this.selectedStoreId) : null;
+ },
+ selectedOffering() {
+ return this.selectedStore ? this.selectedStore.offerings.find(o => o.id === this.selectedOfferingId) : null;
+ },
+ plans() {
+ return this.selectedOffering ? this.selectedOffering.plans : null;
+ },
+ offerings() {
+ return this.selectedStore ? this.selectedStore.offerings : null;
+ }
+ },
+ watch: {
+ selectedStoreId() {
+ this.selectedOfferingId = null;
+ this.selectedPlanId = null;
+ },
+ selectedOfferingId() {
+ this.selectedPlanId = null;
+ }
+ }
+ });
+ });
+</script>
+
diff --git a/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModalViewModel.cs b/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModalViewModel.cs
new file mode 100644
index 0000000..afb9fbc
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/SelectExistingOfferingModalViewModel.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+
+namespace BTCPayServer.Plugins.Monetization.Views;
+
+public class SelectExistingOfferingModalViewModel
+{
+ public string SelectedStoreId { get; set; }
+ public string SelectedOfferingId { get; set; }
+ public string SelectedPlanId { get; set; }
+ public List<Store> Stores { get; set; }
+ public class Item
+ {
+ public string Id { get; set; }
+ public string Name { get; set; }
+ }
+
+ public class Offering : Item
+ {
+ public List<Item> Plans { get; set; }
+ }
+
+ public class Store : Item
+ {
+ public List<Offering> Offerings { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Monetization/Views/UserNavExtension.cshtml b/BTCPayServer/Plugins/Monetization/Views/UserNavExtension.cshtml
new file mode 100644
index 0000000..28986eb
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/UserNavExtension.cshtml
@@ -0,0 +1,9 @@
+@using BTCPayServer.Services
+@inject ISettingsAccessor<MonetizationSettings> Settings
+
+@if (Settings.Settings is { OfferingId: not null, DefaultPlanId: not null })
+{
+ <li class="nav-item nav-item-sub">
+ <a layout-menu-item="ManageBilling" asp-area="@MonetizationPlugin.Area" asp-controller="UIUserMonetization" asp-action="ManageBilling" text-translate="true">Manage billing</a>
+ </li>
+}
diff --git a/BTCPayServer/Plugins/Monetization/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/Monetization/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..e0dcbe7
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/_ViewImports.cshtml
@@ -0,0 +1,11 @@
+@using BTCPayServer.Plugins.Emails
+@using BTCPayServer.Abstractions.Models
+@using BTCPayServer.Views.Stores
+@using BTCPayServer.Client
+@using BTCPayServer.TagHelpers
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@using BTCPayServer.Plugins.Monetization
+
+@namespace BTCPayServer.Plugins.Monetization.Views
+
+
diff --git a/BTCPayServer/Plugins/Monetization/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/Monetization/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..987ee96
--- /dev/null
+++ b/BTCPayServer/Plugins/Monetization/Views/_ViewStart.cshtml
@@ -0,0 +1,4 @@
+@using BTCPayServer.Views.Server
+@{
+ Layout = "_Layout";
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/AppServiceSubscriptionsExtensions.cs b/BTCPayServer/Plugins/Subscriptions/AppServiceSubscriptionsExtensions.cs
new file mode 100644
index 0000000..15a9e69
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/AppServiceSubscriptionsExtensions.cs
@@ -0,0 +1,37 @@
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.Events;
+using BTCPayServer.Services.Apps;
+
+namespace BTCPayServer.Plugins.Subscriptions;
+
+public static class AppServiceSubscriptionsExtensions
+{
+ public static async Task<(string AppId, string OfferingId)> CreateOffering(this AppService appService, string storeId, string name)
+ {
+ var app = new AppData()
+ {
+ Name = name,
+ AppType = SubscriptionsAppType.AppType,
+ StoreDataId = storeId
+ };
+ app.SetSettings(new SubscriptionsAppType.AppConfig());
+ await appService.UpdateOrCreateApp(app, sendEvents: false);
+
+ await using var ctx = appService.ContextFactory.CreateContext();
+ var o = new OfferingData()
+ {
+ AppId = app.Id,
+ };
+ ctx.Offerings.Add(o);
+ await ctx.SaveChangesAsync();
+ app.SetSettings(new SubscriptionsAppType.AppConfig()
+ {
+ OfferingId = o.Id
+ });
+ await appService.UpdateOrCreateApp(app, sendEvents: false);
+ appService.EventAggregator.Publish(new AppEvent.Created(app));
+ return (app.Id, o.Id);
+ }
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
index 30e29d7..06f192d 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@@ -12,7 +11,6 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
-using BTCPayServer.Models;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -22,12 +20,10 @@ using BTCPayServer.Views.UIStoreMembership;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
using DisplayFormatter = BTCPayServer.Services.DisplayFormatter;
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
@@ -45,7 +41,7 @@ public partial class UIOfferingController(
BTCPayServerEnvironment env,
DisplayFormatter displayFormatter,
EmailSenderFactory emailSenderFactory,
- IEnumerable<EmailTriggerViewModel> emailTriggers
+ EmailTriggerViewModels emailTriggers
) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
{
[HttpPost("stores/{storeId}/offerings/{offeringId}/new-subscriber")]
@@ -155,50 +151,50 @@ public partial class UIOfferingController(
if (!ModelState.IsValid)
return View();
- var app = new AppData()
- {
- Name = vm.Name,
- AppType = SubscriptionsAppType.AppType,
- StoreDataId = storeId
- };
- app.SetSettings(new SubscriptionsAppType.AppConfig());
- await appService.UpdateOrCreateApp(app, sendEvents: false);
-
- await using var ctx = DbContextFactory.CreateContext();
- var o = new OfferingData()
- {
- AppId = app.Id,
- };
- ctx.Offerings.Add(o);
- await ctx.SaveChangesAsync();
- app.SetSettings(new SubscriptionsAppType.AppConfig()
- {
- OfferingId = o.Id
- });
- await appService.UpdateOrCreateApp(app, sendEvents: false);
- eventAggregator.Publish(new AppEvent.Created(app));
+ var (_, offeringId) = await appService.CreateOffering(storeId, vm.Name);
this.TempData.SetStatusMessageModel(new()
{
Html = StringLocalizer["New offering created. You can now <a href='{0}' class='alert-link'>configure it.</a>",
- Url.Action(nameof(ConfigureOffering), new { storeId, offeringId = o.Id })!],
+ Url.Action(nameof(ConfigureOffering), new { storeId, offeringId })!],
Severity = StatusMessageModel.StatusSeverity.Success
});
- return GoToOffering(storeId, o.Id, SubscriptionSection.Plans);
+ return GoToOffering(storeId, offeringId, SubscriptionSection.Plans);
}
+
+
+ public static string CreateOfferingCondition(string offeringId)
+ => Predicate(OfferingCondition(offeringId));
+ public static string CreateOfferingCondition(string offeringId, string phase)
+ => Predicate(OfferingCondition(offeringId) + " && " + PhaseCondition(phase));
+
+ public static string CreateOfferingCondition(string offeringId, SubscriberData.PhaseTypes phase)
+ => CreateOfferingCondition(offeringId, phase.ToString());
+
+ static string PhaseCondition(string phase) => $"@.Subscriber.Phase == \"{phase}\"";
+ static string OfferingCondition(string offeringId) => $"@.Offering.Id == \"{offeringId}\"";
+ static string Predicate(string condition) => $"$ ?({condition})";
+
[HttpPost("stores/{storeId}/offerings/{offeringId}/Mails")]
public async Task<IActionResult> SaveMailSettings(string storeId, string offeringId, SubscriptionsViewModel vm, string? addEmailRule = null)
{
await using var ctx = DbContextFactory.CreateContext();
if (addEmailRule is not null)
{
+ var condition = CreateOfferingCondition(offeringId);
+ if (addEmailRule.StartsWith($"{PhaseChangedTrigger}-"))
+ {
+ var phase = addEmailRule.Substring($"{PhaseChangedTrigger}-".Length);
+ addEmailRule = PhaseChangedTrigger;
+ condition = CreateOfferingCondition(offeringId, phase);
+ }
var requestBase = Request.GetRequestBaseUrl();
var link = LinkGenerator.CreateEmailRuleLink(storeId, requestBase, new()
{
OfferingId = offeringId,
Trigger = addEmailRule,
To = "{Subscriber.Email}",
- Condition = $"$.Offering.Id == \"{offeringId}\"",
+ Condition = condition,
RedirectUrl = new Uri(LinkGenerator.OfferingLink(storeId, offeringId, SubscriptionSection.Mails, requestBase)).AbsolutePath
});
return Redirect(link);
@@ -222,6 +218,8 @@ public partial class UIOfferingController(
}
}
+ private static string PhaseChangedTrigger => $"WH-{WebhookSubscriptionEvent.SubscriberPhaseChanged}";
+
[HttpGet("stores/{storeId}/offerings/{offeringId}/{section}")]
public async Task<IActionResult> Offering(string storeId, string offeringId, SubscriptionSection section = SubscriptionSection.Plans,
string? checkoutPlanId = null, string? searchTerm = null)
@@ -304,9 +302,31 @@ public partial class UIOfferingController(
vm.EmailRules = new();
var triggers = emailTriggers
+ .GetViewModels()
.Where(t => WebhookSubscriptionEvent.IsSubscriptionTrigger(t.Trigger))
.ToDictionary(t => t.Trigger);
- vm.AvailableTriggers = triggers.Values.ToList();
+
+ // Those aren't real trigger, we just add trigger condition
+ // on the WH-PhaseChangedTrigger when the user select one of them
+ var phaseChanged = triggers[PhaseChangedTrigger];
+ foreach (string phase in new[] { "Trial", "Normal", "Expired", "Grace" })
+ {
+ var subPhaseChanged = $"{phaseChanged.Trigger}-{phase}";
+ triggers.Add(subPhaseChanged, new()
+ {
+ Trigger = subPhaseChanged,
+ Description = phaseChanged.Description + " - " + StringLocalizer[phase]
+ });
+ }
+ // Remove the suffix "Subscription - "
+ foreach (var trigger in triggers.Values)
+ {
+ var idx = trigger.Description.IndexOf('-');
+ if (idx != -1)
+ trigger.Description = trigger.Description.Substring(idx + 1).Trim();
+ }
+
+ vm.AvailableTriggers = triggers.Values.OrderBy(t => t.Description).ToList();
foreach (var emailRule in
await ctx.EmailRules
.Where(r => r.StoreId == storeId && r.OfferingId == offeringId)
@@ -509,7 +529,11 @@ public partial class UIOfferingController(
if (plan is null && planId is not null)
return NotFound();
- plan ??= new PlanData();
+ plan ??= new PlanData()
+ {
+ CreatedAt = DateTimeOffset.UtcNow,
+ PlanEntitlements = new()
+ };
plan.Name = vm.Name;
plan.Description = vm.Description;
plan.Price = vm.Price;
@@ -518,11 +542,8 @@ public partial class UIOfferingController(
plan.TrialDays = vm.TrialDays;
plan.OptimisticActivation = vm.OptimisticActivation;
plan.Renewable = vm.Renewable;
- if (planId is null)
- plan.CreatedAt = DateTimeOffset.UtcNow;
plan.RecurringType = vm.RecurringType;
plan.OfferingId = vm.OfferingId;
- plan.PlanEntitlements ??= new();
plan.PlanChanges ??= new();
foreach (var vmPC in vm.PlanChanges)
@@ -562,7 +583,6 @@ public partial class UIOfferingController(
}
await ctx.SaveChangesAsync();
- eventAggregator.Publish(new SubscriptionEvent.PlanUpdated(plan));
var customIdsToIds = offering.Entitlements.ToDictionary(x => x.CustomId, x => x.Id);
var enabled = vm.Entitlements.Where(e => e.Selected).Select(e => customIdsToIds[e.CustomId]).ToArray();
@@ -574,11 +594,12 @@ public partial class UIOfferingController(
SELECT @planId, e FROM unnest(@enabled) e
ON CONFLICT DO NOTHING;
""", new { planId = plan.Id, enabled });
-
+ await plan.ReloadEntitlement(ctx);
if (planId is null)
this.TempData.SetStatusSuccess(StringLocalizer["New plan created"]);
else
this.TempData.SetStatusSuccess(StringLocalizer["Plan edited"]);
+ eventAggregator.Publish(new SubscriptionEvent.PlanUpdated(plan));
return GoToOffering(plan.Offering.App.StoreDataId, plan.OfferingId);
}
@@ -593,7 +614,6 @@ public partial class UIOfferingController(
var portal = new PortalSessionData()
{
SubscriberId = sub.Id,
- Expiration = DateTimeOffset.UtcNow + TimeSpan.FromHours(1.0),
BaseUrl = Request.GetRequestBaseUrl()
};
ctx.PortalSessions.Add(portal);
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
index 5762936..eb9bbc1 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
@@ -63,7 +63,7 @@ public class UIPlanCheckoutController(
return InvoiceMetadata.FromJObject(JObject.Parse(checkout.InvoiceMetadata));
}
- [HttpGet("plan-checkout/default-redirect")]
+ [HttpGet("~/plan-checkout/default-redirect")]
public async Task<IActionResult> PlanCheckoutDefaultRedirect(string? checkoutPlanId = null)
{
if (checkoutPlanId is null)
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
index 72cb4ca..0c11fe9 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
@@ -92,7 +92,9 @@ public class UISubscriberPortalController(
StoreName = store.StoreName,
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, store.GetStoreBlob()),
PlanChanges = planChanges,
- Refund = (refundValue, displayFormatter.Currency(refundValue, curr, DisplayFormatter.CurrencyFormat.Symbol))
+ Refund = (refundValue, displayFormatter.Currency(refundValue, curr, DisplayFormatter.CurrencyFormat.Symbol)),
+ Url = string.IsNullOrEmpty(store.StoreWebsite) ? Request.GetRequestBaseUrl().ToString() : store.StoreWebsite,
+ BTCPayLogo = Url.Content("~/img/btcpay-logo.svg")
};
var creditHist = await ctx.SubscriberCreditHistory
.Where(s => s.SubscriberId == session.SubscriberId && s.Currency == session.Subscriber.Plan.Currency)
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs b/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
index 8c2728f..55b862e 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
@@ -42,18 +42,22 @@ public class SubscriptionContext(ApplicationDbContext ctx, EventAggregator aggre
var diff = tx.Diff;
if (diff >= 0)
force = true;
- var amountCondition = force ? "1=1" : "c.amount >= -@diff";
+ var amountCondition = force ? "1=1" : "new_amount >= 0";
var amount = await ctx.Database.GetDbConnection()
.ExecuteScalarAsync<decimal?>($"""
WITH
+ change AS (
+ SELECT o.id, o.currency, COALESCE(c.amount, 0) + o.diff AS new_amount
+ FROM (SELECT @id id, @currency currency, @diff diff) AS o
+ LEFT JOIN subs_subscriber_credits c ON c.subscriber_id = o.id AND c.currency = o.currency
+ Why this scored 40/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.