[Feature] Store-scoped labels + linkedType-scoped LabelManager suggestions (#7050)
What changed, and why it matters
This commit refactors how BTCPay Server stores labels for payment requests, moving them from a wallet-based graph system into new store-scoped database tables. It also adds a new API endpoint for updating store-scoped labels. The changes are primarily a feature/refactoring effort, but they include security-relevant hardening such as CSRF token validation on the new update endpoint, a check that the target store exists and matches the route, and restrictions preventing deletion or renaming of reserved system label types. A migration copies existing payment-request labels into the new tables and removes the old wallet-graph entries.
No immediate action required; this is a feature/refactoring commit with defensive hardening. Reviewers should verify that the new UpdateStoreLabels endpoint enforces the intended authorization policy in all deployment configurations, that the anti-forgery token header name matches the server-side antiforgery configuration, and that the migration correctly handles large datasets and concurrent updates. Consider whether the StoreLabelsJson GET endpoint returning all store labels for a linked type could leak sensitive label names to any user with store access.
Security signals we found
New POST endpoint /stores/{storeId}/update-labels is protected with [Authorize(Policy = Policies.CanModifyStoreSettings)] and [ValidateAntiForgeryToken]
Client-side JS extracts and sends the anti-forgery token in the RequestVerificationToken header for label updates
Store-scoped label endpoints verify CurrentStore exists and matches the route storeId before acting
Delete/Rename payment-request label actions now reject reserved WalletObjectData.Types.AllTypes labels
Migration uses raw SQL but operates on existing data with ON CONFLICT DO NOTHING and parameterized Dapper queries in repository code
Label text is normalized, trimmed, and capped at 50 characters to limit injection surface
Case-insensitive unique index on store_id, type, lower(text) prevents duplicate label creation
Evidence from the diff
The commit introduces StoreLabelData/StoreLabelLinkData entities, a StoreLabelRepository, and new UIStoresController endpoints (StoreLabelsJson, UpdateStoreLabels). Payment request label handling is migrated from WalletRepository to StoreLabelRepository. Security signals include: [ValidateAntiForgeryToken] on UpdateStoreLabels; store existence/authorization checks in UpdateStoreLabels and DeletePaymentRequestLabel/RenamePaymentRequestLabel; prevention of deleting/renaming WalletObjectData.Types.AllTypes reserved labels; and migration SQL that uses parameterized Dapper queries and ON CONFLICT. The client-side JS now sends the anti-forgery token as RequestVerificationToken header. Potential concerns: the StoreLabelsJson GET endpoint uses [IgnoreAntiforgeryToken] but only returns label metadata for the current store; label text is normalized and truncated to 50 chars; case-insensitive merging is enforced via DB index and repository logic.
Changed components
BTCPayServer.Data (ApplicationDbContext, StoreLabelData, StoreLabelLinkData, migration)BTCPayServer.Services.Labels.StoreLabelRepositoryBTCPayServer.Controllers.UIStoresController (new Labels partial)BTCPayServer.Controllers.UIPaymentRequestControllerBTCPayServer.Controllers.UIWalletsController.LabelsJsonBTCPayServer.Components.LabelManagerBTCPayServer.Services.PaymentRequests.PaymentRequestRepositoryBTCPayServer.Services.Reporting.PaymentRequestsReportProviderBTCPayServer/wwwroot/main/site.jsInspect captured patch +1155 / −166
diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs
index d169a44..f6e2108 100644
--- a/BTCPayServer.Data/ApplicationDbContext.cs
+++ b/BTCPayServer.Data/ApplicationDbContext.cs
@@ -46,6 +46,8 @@ namespace BTCPayServer.Data
public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<Fido2Credential> Fido2Credentials { get; set; }
public DbSet<UserStore> UserStore { get; set; }
+ public DbSet<StoreLabelData> StoreLabels { get; set; }
+ public DbSet<StoreLabelLinkData> StoreLabelLinks { get; set; }
public DbSet<StoreRole> StoreRoles { get; set; }
[Obsolete]
public DbSet<WalletData> Wallets { get; set; }
@@ -93,6 +95,8 @@ namespace BTCPayServer.Data
PullPaymentData.OnModelCreating(builder, Database);
RefundData.OnModelCreating(builder);
SettingData.OnModelCreating(builder, Database);
+ StoreLabelData.OnModelCreating(builder);
+ StoreLabelLinkData.OnModelCreating(builder);
StoreSettingData.OnModelCreating(builder, Database);
StoreWebhookData.OnModelCreating(builder);
StoreData.OnModelCreating(builder, Database);
diff --git a/BTCPayServer.Data/Data/StoreLabelData.cs b/BTCPayServer.Data/Data/StoreLabelData.cs
new file mode 100644
index 0000000..570c6b7
--- /dev/null
+++ b/BTCPayServer.Data/Data/StoreLabelData.cs
@@ -0,0 +1,35 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Data;
+
+public class StoreLabelData
+{
+ public string StoreId { get; set; } = null!;
+ public string Id { get; set; } = null!;
+ public string Type { get; set; } = null!;
+ public string Text { get; set; } = null!;
+ public string? Color { get; set; }
+
+ [Timestamp]
+ public uint XMin { get; set; }
+
+ internal static void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity<StoreLabelData>(b =>
+ {
+ b.ToTable("store_labels");
+
+ b.HasKey(x => new { x.StoreId, x.Id });
+
+ b.Property(x => x.StoreId).HasColumnName("store_id");
+ b.Property(x => x.Id).HasColumnName("id");
+ b.Property(x => x.Type).HasColumnName("type");
+ b.Property(x => x.Text).HasColumnName("text");
+ b.Property(x => x.Color).HasColumnName("color");
+ b.Property(x => x.XMin).HasColumnName("xmin");
+
+ });
+ }
+}
diff --git a/BTCPayServer.Data/Data/StoreLabelLinkData.cs b/BTCPayServer.Data/Data/StoreLabelLinkData.cs
new file mode 100644
index 0000000..a15e274
--- /dev/null
+++ b/BTCPayServer.Data/Data/StoreLabelLinkData.cs
@@ -0,0 +1,40 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Data;
+
+public class StoreLabelLinkData
+{
+ public string StoreId { get; set; } = null!;
+ public string StoreLabelId { get; set; } = null!;
+ public string ObjectId { get; set; } = null!;
+
+ public StoreLabelData StoreLabel { get; set; } = null!;
+
+ [Timestamp]
+ public uint XMin { get; set; }
+
+ public static void OnModelCreating(ModelBuilder builder)
+ {
+ builder.Entity<StoreLabelLinkData>(b =>
+ {
+ b.ToTable("store_label_links");
+
+ b.HasKey(x => new { x.StoreId, x.StoreLabelId, x.ObjectId });
+
+ b.Property(x => x.StoreId).HasColumnName("store_id");
+ b.Property(x => x.StoreLabelId).HasColumnName("store_label_id");
+ b.Property(x => x.ObjectId).HasColumnName("object_id");
+ b.Property(x => x.XMin).HasColumnName("xmin");
+
+ b.HasIndex(x => new { x.StoreId, x.ObjectId });
+
+ b.HasOne(x => x.StoreLabel)
+ .WithMany()
+ .HasForeignKey(x => new { x.StoreId, x.StoreLabelId })
+ .HasPrincipalKey(x => new { x.StoreId, x.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/20260114053517_StoreScopedLabels.cs b/BTCPayServer.Data/Migrations/20260114053517_StoreScopedLabels.cs
new file mode 100644
index 0000000..fe37b6b
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20260114053517_StoreScopedLabels.cs
@@ -0,0 +1,162 @@
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260114053517_StoreScopedLabels")]
+ public partial class StoreScopedLabels : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "store_labels",
+ columns: table => new
+ {
+ store_id = table.Column<string>(type: "text", nullable: false),
+ id = table.Column<string>(type: "text", nullable: false),
+ type = table.Column<string>(type: "text", nullable: false),
+ text = table.Column<string>(type: "text", nullable: false),
+ color = table.Column<string>(type: "text", nullable: true),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_store_labels", x => new { x.store_id, x.id });
+ });
+
+ migrationBuilder.Sql(
+ """
+ CREATE UNIQUE INDEX "IX_store_labels_store_id_type_text_lower"
+ ON store_labels (store_id, type, lower(text));
+ """);
+
+
+ migrationBuilder.CreateTable(
+ name: "store_label_links",
+ columns: table => new
+ {
+ store_id = table.Column<string>(type: "text", nullable: false),
+ store_label_id = table.Column<string>(type: "text", nullable: false),
+ object_id = table.Column<string>(type: "text", nullable: false),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_store_label_links", x => new { x.store_id, x.store_label_id, x.object_id });
+ table.ForeignKey(
+ name: "FK_store_label_links_store_labels_store_id_store_label_id",
+ columns: x => new { x.store_id, x.store_label_id },
+ principalTable: "store_labels",
+ principalColumns: new[] { "store_id", "id" },
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_store_label_links_store_id_object_id",
+ table: "store_label_links",
+ columns: new[] { "store_id", "object_id" });
+
+ // Copy Payment Request label objects (label metadata) into StoreLabels
+ migrationBuilder.Sql(@"
+ WITH pr_links AS (
+ SELECT DISTINCT
+ wol.""WalletId"" AS ""WalletId"",
+ wol.""AId"" AS ""LabelText"",
+ wol.""BId"" AS ""PaymentRequestId""
+ FROM ""WalletObjectLinks"" wol
+ WHERE wol.""AType"" = 'label'
+ AND wol.""BType"" = 'payment-request'
+ ),
+ pr_labels AS (
+ SELECT DISTINCT
+ pr.""StoreDataId"" AS ""StoreId"",
+ pl.""LabelText"",
+ wo.""Data"" AS ""LabelData""
+ FROM pr_links pl
+ INNER JOIN ""PaymentRequests"" pr
+ ON pr.""Id"" = pl.""PaymentRequestId""
+ INNER JOIN ""WalletObjects"" wo
+ ON wo.""WalletId"" = pl.""WalletId""
+ AND wo.""Type"" = 'label'
+ AND wo.""Id"" = pl.""LabelText""
+ )
+ INSERT INTO store_labels (store_id, id, type, text, color)
+ SELECT
+ ""StoreId"",
+ gen_random_uuid()::text,
+ 'payment-request',
+ ""LabelText"",
+ (""LabelData""::jsonb ->> 'color')
+ FROM pr_labels
+ ON CONFLICT (store_id, type, (lower(text))) DO NOTHING;
+ ");
+
+ // Copy Payment Request label links into StoreLabelLinks
+ migrationBuilder.Sql(@"
+ WITH pr_links AS (
+ SELECT DISTINCT
+ pr.""StoreDataId"" AS ""StoreId"",
+ wol.""AId"" AS ""LabelText"",
+ wol.""BId"" AS ""ObjectId""
+ FROM ""WalletObjectLinks"" wol
+ INNER JOIN ""PaymentRequests"" pr
+ ON pr.""Id"" = wol.""BId""
+ WHERE wol.""AType"" = 'label'
+ AND wol.""BType"" = 'payment-request'
+ )
+ INSERT INTO store_label_links (store_id, store_label_id, object_id)
+ SELECT
+ pl.""StoreId"",
+ sl.id AS store_label_id,
+ pl.""ObjectId""
+ FROM pr_links pl
+ INNER JOIN store_labels sl
+ ON sl.store_id = pl.""StoreId""
+ AND sl.type = 'payment-request'
+ AND sl.text = pl.""LabelText""
+ ON CONFLICT (store_id, store_label_id, object_id) DO NOTHING;
+ ");
+
+ // Remove the Payment Request label links from the wallet graph
+ migrationBuilder.Sql(@"
+ DELETE FROM ""WalletObjectLinks"" wol
+ WHERE wol.""AType"" = 'label'
+ AND wol.""BType"" = 'payment-request';
+ ");
+
+ // Remove unlinked Labels from WalletObjects
+ migrationBuilder.Sql(@"
+ WITH pr_wallets AS (
+ SELECT DISTINCT wo.""WalletId""
+ FROM ""WalletObjects"" wo
+ WHERE wo.""Type"" = 'payment-request'
+ )
+ DELETE FROM ""WalletObjects"" wo
+ WHERE wo.""Type"" = 'label'
+ AND wo.""WalletId"" IN (SELECT ""WalletId"" FROM pr_wallets)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM ""WalletObjectLinks"" wol
+ WHERE wol.""WalletId"" = wo.""WalletId""
+ AND (
+ (wol.""AType"" = 'label' AND wol.""AId"" = wo.""Id"")
+ OR (wol.""BType"" = 'label' AND wol.""BId"" = wo.""Id"")
+ )
+ );
+ ");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "store_label_links");
+
+ migrationBuilder.DropTable(
+ name: "store_labels");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index b4a53c1..8a268d3 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -956,6 +956,68 @@ namespace BTCPayServer.Migrations
b.ToTable("Stores");
});
+ modelBuilder.Entity("BTCPayServer.Data.StoreLabelData", b =>
+ {
+ b.Property<string>("StoreId")
+ .HasColumnType("text")
+ .HasColumnName("store_id");
+
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property<string>("Text")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("text");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<uint>("XMin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("StoreId", "Id");
+
+ b.ToTable("store_labels", (string)null);
+ });
+
+ modelBuilder.Entity("BTCPayServer.Data.StoreLabelLinkData", b =>
+ {
+ b.Property<string>("StoreId")
+ .HasColumnType("text")
+ .HasColumnName("store_id");
+
+ b.Property<string>("StoreLabelId")
+ .HasColumnType("text")
+ .HasColumnName("store_label_id");
+
+ b.Property<string>("ObjectId")
+ .HasColumnType("text")
+ .HasColumnName("object_id");
+
+ b.Property<uint>("XMin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("StoreId", "StoreLabelId", "ObjectId");
+
+ b.HasIndex("StoreId", "ObjectId");
+
+ b.ToTable("store_label_links", (string)null);
+ });
+
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.Property<string>("Id")
@@ -2212,6 +2274,17 @@ namespace BTCPayServer.Migrations
b.Navigation("PullPaymentData");
});
+ modelBuilder.Entity("BTCPayServer.Data.StoreLabelLinkData", b =>
+ {
+ b.HasOne("BTCPayServer.Data.StoreLabelData", "StoreLabel")
+ .WithMany()
+ .HasForeignKey("StoreId", "StoreLabelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("StoreLabel");
+ });
+
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs
index 741bda5..f7eb232 100644
--- a/BTCPayServer.Tests/BTCPayServerTester.cs
+++ b/BTCPayServer.Tests/BTCPayServerTester.cs
@@ -254,6 +254,7 @@ namespace BTCPayServer.Tests
var kraken = new MockRateProvider(realKraken.RateSourceInfo);
kraken.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETH_BTC"), new BidAsk(0.1m)));
kraken.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("BTC_LTC"), new BidAsk(162m)));
+ kraken.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("BTC_USD"), new BidAsk(5000m)));
rateProvider.Providers.Add("kraken", kraken);
foreach (var prov in rateProvider.Providers)
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index 9633ff1..581e06a 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -513,6 +513,18 @@ namespace BTCPayServer.Tests
await Page.Locator("#page-primary").ClickAsync();
}
+ public async Task AddStoreLabelAsync(ILocator row, string label)
+ {
+ var labelInput = row.Locator(".ts-control input");
+ await labelInput.WaitForAsync();
+ await labelInput.FillAsync(label);
+ var resp = await Page.RunAndWaitForResponseAsync(
+ () => labelInput.PressAsync("Enter"),
+ r => r.Request.Method == "POST" &&
+ r.Url.Contains("/update-labels", StringComparison.OrdinalIgnoreCase));
+ Assert.True(resp.Ok, $"update-labels returned {resp.Status}");
+ }
+
public async Task GoToWalletSettings(string cryptoCode = "BTC")
{
await Page.GetByTestId("Wallet-" + cryptoCode).Locator("a").ClickAsync();
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 70ca0b1..936f0e9 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1269,15 +1269,7 @@ namespace BTCPayServer.Tests
// Labels
const string labelName = "test-label";
var testPrRow = s.Page.Locator("table tbody tr", new PageLocatorOptions { HasText = paymentRequestTitle });
- var labelInput = testPrRow.Locator(".ts-control input");
- await Expect(labelInput).ToBeVisibleAsync();
-
- await labelInput.FillAsync(labelName);
- var resp = await s.Page.RunAndWaitForResponseAsync(
- () => labelInput.PressAsync("Enter"),
- r => r.Request.Method == "POST" &&
- r.Url.Contains("/update-labels", StringComparison.OrdinalIgnoreCase));
- Assert.True(resp.Ok, $"update-labels returned {resp.Status}");
+ await s.AddStoreLabelAsync(testPrRow, labelName);
await TestUtils.EventuallyAsync(async () =>
{
@@ -1316,6 +1308,65 @@ namespace BTCPayServer.Tests
Assert.Equal(2, await allPrRows.CountAsync());
}
+ [Fact]
+ public async Task CanUseStoreLabels()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet("BTC", "", true);
+
+ await s.GoToStore();
+ await s.Page.ClickAsync("#menu-item-PaymentRequests");
+
+ await s.ClickPagePrimary();
+ var paymentRequestTitle1 = "Label Case PR 1";
+ await s.Page.FillAsync("#Title", paymentRequestTitle1);
+ await s.Page.FillAsync("#Amount", "0.1");
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage(partialText: "Payment request");
+
+ await s.Page.ClickAsync("#menu-item-PaymentRequests");
+ await s.ClickPagePrimary();
+ var paymentRequestTitle2 = "Label Case PR 2";
+ await s.Page.FillAsync("#Title", paymentRequestTitle2);
+ await s.Page.FillAsync("#Amount", "0.2");
+ await s.Page.FillAsync("#Currency", "BTC");
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage(partialText: "Payment request");
+
+ await s.Page.ClickAsync("#menu-item-PaymentRequests");
+ await s.Page.WaitForLoadStateAsync();
+
+ const string labelOriginal = "Case";
+ const string labelLower = "case";
+
+ var row1 = s.Page.Locator("table tbody tr", new PageLocatorOptions { HasText = paymentRequestTitle1 });
+ await s.AddStoreLabelAsync(row1, labelOriginal);
+
+ var row2 = s.Page.Locator("table tbody tr", new PageLocatorOptions { HasText = paymentRequestTitle2 });
+ await s.AddStoreLabelAsync(row2, labelLower);
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var text1 = await row1.InnerTextAsync();
+ var text2 = await row2.InnerTextAsync();
+ Assert.Contains(labelOriginal, text1);
+ Assert.Contains(labelOriginal, text2);
+ });
+
+ await s.Page.ReloadAsync();
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.WaitForSelectorAsync("#LabelOptionsToggle");
+ await s.Page.ClickAsync("#LabelOptionsToggle");
+ var labelItems = await s.Page.Locator(".dropdown-menu a").AllInnerTextsAsync();
+ var matches = labelItems.Where(t => t.Equals(labelOriginal, StringComparison.OrdinalIgnoreCase)).ToArray();
+ Assert.Single(matches);
+ Assert.Equal(labelOriginal, matches[0]);
+ }
+
[Fact]
public async Task CanRequireApprovalForNewAccounts()
{
@@ -2897,5 +2948,3 @@ namespace BTCPayServer.Tests
}
}
-
-
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
index 4e03193..9d547e4 100644
--- a/BTCPayServer.Tests/SubscriptionTests.cs
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -214,22 +214,22 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
// Note that at this point, the customer has a period of 15 days + 1 month.
// This is because the trial period is 15 days, so we extend the plan.
await portal.GoTo7Days();
+ // The downgrade can be paid by the current, more expensive plan.
+ var unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 299.0m, daysInPeriod: 15 + DaysInTrialEndMonth(15));
await portal.Downgrade("Pro Plan");
var totalRefunded = 0m;
- // The downgrade can be paid by the current, more expensive plan.
- var unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 299.0m, daysInPeriod: 15 + DaysInThisMonth());
totalRefunded += await portal.AssertRefunded(unused);
var expectedBalance = totalRefunded - 99.0m;
- await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
+ await portal.AssertCredit(creditBalance: $"${expectedBalance.ToString("F2", CultureInfo.InvariantCulture)}");
// This time, we should have 1 month in the current period.
await portal.GoTo7Days();
+ unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
var credited = await s.Server.WaitForEvent<SubscriptionEvent.SubscriberCredited>(async () =>
{
await portal.Downgrade("Basic Plan");
- unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
unused = await portal.AssertRefunded(unused);
totalRefunded += unused;
});
@@ -239,17 +239,17 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
expectedBalance = totalRefunded - 29.0m - 99.0m;
- await portal.AssertCredit("$29.00", "-$29.00", "$0.00", $"${expectedBalance:F2}");
+ await portal.AssertCredit("$29.00", "-$29.00", "$0.00", $"${expectedBalance.ToString("F2", CultureInfo.InvariantCulture)}");
// The balance should now be around 202.15 USD
// Now, let's try upgrade. Since we have enough money, we should be able to upgrade without invoice.
await portal.GoTo7Days();
- await portal.Upgrade("Pro Plan");
unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 29.0m, daysInPeriod: DaysInThisMonth());
+ await portal.Upgrade("Pro Plan");
unused = await portal.AssertRefunded(unused);
totalRefunded += unused;
expectedBalance = totalRefunded - 29.0m - 99.0m - 99.0m;
- await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
+ await portal.AssertCredit(creditBalance: $"${expectedBalance.ToString("F2", CultureInfo.InvariantCulture)}");
// However, for going back to enterprise, we do not have enough.
await portal.GoTo7Days();
@@ -275,7 +275,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
"$" + (299m - expectedBalance - unused).ToString("F2", CultureInfo.InvariantCulture)
]);
expectedBalance = 0m;
- await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
+ await portal.AssertCredit(creditBalance: $"${expectedBalance.ToString("F2", CultureInfo.InvariantCulture)}");
}
}
@@ -291,6 +291,11 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
return DateTime.DaysInMonth(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month);
}
+ private static int DaysInTrialEndMonth(int trialDays)
+ {
+ var trialEnd = DateTimeOffset.UtcNow.AddDays(trialDays);
+ return DateTime.DaysInMonth(trialEnd.Year, trialEnd.Month);
+ }
[Fact]
[Trait("Integration", "Integration")]
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index b667486..1e93254 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -3203,6 +3203,129 @@ namespace BTCPayServer.Tests
});
}
+ [Fact(Timeout = LongRunningTestTimeout)]
+ [Trait("Integration", "Integration")]
+ public async Task CanMigratePaymentRequestLabelsToStoreScopedTables()
+ {
+ var tester = CreateDBTester();
+
+ const string migrationId = "20260114053517_StoreScopedLabels";
+
+ await tester.MigrateUntil(migrationId);
+
+ await using var ctx = tester.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+
+ const string storeId = "TestStore12345678901234";
+ const string walletId = "S-TestStore12345678901234-BTC";
+
+ const string prId = "pr-1";
+ const string orphanLabelText = "orphan-label";
+ const string sharedLabelText = "shared-label";
+
+ await conn.ExecuteAsync("""
+ INSERT INTO "Stores" ("Id", "SpeedPolicy") VALUES (@storeId, 0);
+
+ INSERT INTO "PaymentRequests" ("Id", "StoreDataId", "Status", "Created", "Archived")
+ VALUES (@prId, @storeId, 'Pending', now(), FALSE);
+
+ INSERT INTO "WalletObjects" ("WalletId", "Type", "Id", "Data")
+ VALUES
+ (@walletId, 'payment-request', @prId, NULL),
+ (@walletId, 'label', @orphanLabelId, '{"color": "#84b6eb"}'),
+ (@walletId, 'label', @sharedLabelId, '{"color": "#fbca04"}'),
+ (@walletId, 'invoice', 'inv-1', NULL);
+
+ INSERT INTO "WalletObjectLinks" ("WalletId", "AType", "AId", "BType", "BId", "Data")
+ VALUES
+ (@walletId, 'label', @orphanLabelId, 'payment-request', @prId, NULL),
+ (@walletId, 'label', @sharedLabelId, 'payment-request', @prId, NULL),
+ (@walletId, 'label', @sharedLabelId, 'invoice', 'inv-1', NULL);
+ """, new { storeId, walletId, prId, orphanLabelId = orphanLabelText,
+ sharedLabelId = sharedLabelText });
+
+ Assert.Equal(1, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*) FROM "PaymentRequests"
+ WHERE "Id" = @prId AND "StoreDataId" = @storeId;
+ """, new { storeId, prId }));
+
+ Assert.Equal(3, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*) FROM "WalletObjectLinks"
+ WHERE "WalletId" = @walletId;
+ """, new { walletId }));
+
+ Assert.Equal(2, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*) FROM "WalletObjects"
+ WHERE "WalletId" = @walletId AND "Type" = 'label';
+ """, new { walletId }));
+
+ await tester.CompleteMigrations();
+
+ Assert.True(await conn.QuerySingleAsync<bool>("""
+ SELECT EXISTS (
+ SELECT 1
+ FROM pg_proc
+ WHERE proname = 'gen_random_uuid'
+ );
+ """));
+
+ Assert.True(await conn.QuerySingleAsync<bool>("""
+ SELECT EXISTS (
+ SELECT 1 FROM "__EFMigrationsHistory"
+ WHERE "MigrationId" = @migrationId
+ );
+ """, new { migrationId }));
+
+ Assert.Equal(2, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM store_labels
+ WHERE store_id = @storeId
+ AND type = 'payment-request'
+ AND text = ANY(@texts);
+ """, new { storeId, texts = new[] { orphanLabelText, sharedLabelText } }));
+
+
+ Assert.Equal(2, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM store_label_links sll
+ INNER JOIN store_labels sl
+ ON sl.store_id = sll.store_id
+ AND sl.id = sll.store_label_id
+ WHERE sll.store_id = @storeId
+ AND sll.object_id = @prId
+ AND sl.type = 'payment-request';
+ """, new { storeId, prId }));
+
+
+ Assert.Equal(0, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM "WalletObjectLinks" wol
+ WHERE wol."WalletId" = @walletId
+ AND wol."AType" = 'label'
+ AND wol."BType" = 'payment-request';
+ """, new { walletId }));
+
+ Assert.Equal(0, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM "WalletObjects"
+ WHERE "WalletId" = @walletId AND "Type" = 'label' AND "Id" = @orphanLabelId;
+ """, new { walletId, orphanLabelId = orphanLabelText }));
+
+ Assert.Equal(1, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM "WalletObjects"
+ WHERE "WalletId" = @walletId AND "Type" = 'label' AND "Id" = @sharedLabelId;
+ """, new { walletId, sharedLabelId = sharedLabelText }));
+
+ Assert.Equal(1, await conn.QuerySingleAsync<long>("""
+ SELECT COUNT(*)
+ FROM "WalletObjectLinks"
+ WHERE "WalletId" = @walletId
+ AND "AType" = 'label' AND "AId" = @sharedLabelId
+ AND "BType" = 'invoice' AND "BId" = 'inv-1';
+ """, new { walletId, sharedLabelId = sharedLabelText }));
+ }
+
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanMigratePaymentRequestsTitles()
@@ -3275,6 +3398,8 @@ namespace BTCPayServer.Tests
Assert.Equal(JObject.Parse(actualBlob2), expectedBlob2);
}
+
+
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanDoInvoiceMigrations()
diff --git a/BTCPayServer/Components/LabelManager/Default.cshtml b/BTCPayServer/Components/LabelManager/Default.cshtml
index e50515f..2c0a6ec 100644
--- a/BTCPayServer/Components/LabelManager/Default.cshtml
+++ b/BTCPayServer/Components/LabelManager/Default.cshtml
@@ -3,20 +3,58 @@
@model BTCPayServer.Components.LabelManager.LabelViewModel
@{
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
- var fetchUrl = Url.Action("LabelsJson", "UIWallets", new {
- walletId = Model.WalletObjectId.WalletId,
- excludeTypes = Safe.Json(Model.ExcludeTypes)
- });
- var updateUrl = Model.AutoUpdate? Url.Action("UpdateLabels", "UIWallets", new {
- walletId = Model.WalletObjectId.WalletId
- }): string.Empty;
+
+ var isWalletScoped = Model.WalletObjectId is not null;
+
+ if (!isWalletScoped && (string.IsNullOrEmpty(Model.StoreId) ||
+ string.IsNullOrEmpty(Model.LinkedType) ||
+ (Model.AutoUpdate && string.IsNullOrEmpty(Model.StoreObjectId))))
+ throw new InvalidOperationException("LabelManager store-scoped rendering requires StoreId, LinkedType and StoreObjectId when AutoUpdate is enabled.");
+
+ var objectType = isWalletScoped ? Model.WalletObjectId.Type : Model.LinkedType;
+ var objectId = isWalletScoped ? Model.WalletObjectId.Id : Model.StoreObjectId;
+
+ var fetchUrl = isWalletScoped
+ ? Url.Action("LabelsJson", "UIWallets", new {
+ walletId = Model.WalletObjectId.WalletId,
+ excludeTypes = Safe.Json(Model.ExcludeTypes),
+ linkedType = Model.LinkedType
+ })
+ : Url.Action("StoreLabelsJson", "UIStores", new {
+ storeId = Model.StoreId,
+ excludeTypes = Safe.Json(Model.ExcludeTypes),
+ linkedType = Model.LinkedType
+ });
+
+ var updateUrl = !Model.AutoUpdate
+ ? string.Empty
+ : isWalletScoped
+ ? Url.Action("UpdateLabels", "UIWallets", new { walletId = Model.WalletObjectId.WalletId })
+ : Url.Action("UpdateStoreLabels", "UIStores", new { storeId = Model.StoreId });
}
-<input id="@elementId" placeholder=@StringLocalizer["Select labels"] autocomplete="off" value="@string.Join(",", Model.SelectedLabels)"
+
+@Html.AntiForgeryToken()
+<input id="@elementId"
+ placeholder=@StringLocalizer["Select labels"]
+ autocomplete="off"
+ value="@string.Join(",", Model.SelectedLabels ?? Array.Empty<string>())"
class="only-for-js form-control label-manager ts-wrapper @(Model.DisplayInline ? "ts-inline" : "")"
+ data-scope="@(isWalletScoped ? "wallet" : "store")"
data-fetch-url="@fetchUrl"
data-update-url="@updateUrl"
- data-wallet-id="@Model.WalletObjectId.WalletId"
- data-wallet-object-id="@Model.WalletObjectId.Id"
- data-wallet-object-type="@Model.WalletObjectId.Type"
+ data-object-id="@objectId"
+ data-object-type="@objectType"
data-select-element="@Model.SelectElement"
- data-labels='@Safe.Json(Model.RichLabelInfo)' />
+ data-labels='@Safe.Json(Model.RichLabelInfo)'
+ @if (isWalletScoped)
+ {
+ <text>
+ data-wallet-id="@Model.WalletObjectId.WalletId"
+ </text>
+ }
+ else
+ {
+ <text>
+ data-store-id="@Model.StoreId"
+ </text>
+ } />
diff --git a/BTCPayServer/Components/LabelManager/LabelManager.cs b/BTCPayServer/Components/LabelManager/LabelManager.cs
index e64ff32..663a35c 100644
--- a/BTCPayServer/Components/LabelManager/LabelManager.cs
+++ b/BTCPayServer/Components/LabelManager/LabelManager.cs
@@ -7,17 +7,30 @@ namespace BTCPayServer.Components.LabelManager
{
public class LabelManager : ViewComponent
{
- public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary<string, RichLabelInfo> richLabelInfo = null, bool autoUpdate = true, string selectElement = null)
+ public IViewComponentResult Invoke(
+ string[] selectedLabels,
+ bool excludeTypes = true,
+ bool displayInline = false,
+ Dictionary<string, RichLabelInfo> richLabelInfo = null,
+ bool autoUpdate = true,
+ string selectElement = null,
+ string linkedType = null,
+ WalletObjectId walletObjectId = null,
+ string storeId = null,
+ string storeObjectId = null)
{
var vm = new LabelViewModel
{
- ExcludeTypes = excludeTypes,
WalletObjectId = walletObjectId,
- SelectedLabels = selectedLabels ?? Array.Empty<string>(),
+ SelectedLabels = selectedLabels,
+ ExcludeTypes = excludeTypes,
DisplayInline = displayInline,
RichLabelInfo = richLabelInfo,
AutoUpdate = autoUpdate,
- SelectElement = selectElement
+ SelectElement = selectElement,
+ LinkedType = linkedType,
+ StoreId = storeId,
+ StoreObjectId = storeObjectId
};
return View(vm);
}
diff --git a/BTCPayServer/Components/LabelManager/LabelViewModel.cs b/BTCPayServer/Components/LabelManager/LabelViewModel.cs
index d0b9baf..8756553 100644
--- a/BTCPayServer/Components/LabelManager/LabelViewModel.cs
+++ b/BTCPayServer/Components/LabelManager/LabelViewModel.cs
@@ -12,5 +12,8 @@ namespace BTCPayServer.Components.LabelManager
public Dictionary<string, RichLabelInfo> RichLabelInfo { get; set; }
public bool AutoUpdate { get; set; }
public string SelectElement { get; set; }
+ public string LinkedType { get; set; }
+ public string StoreId { get; set; }
+ public string StoreObjectId { get; set; }
}
}
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index 20ea554..487c0d2 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -49,7 +49,8 @@ namespace BTCPayServer.Controllers
private readonly StoreRepository _storeRepository;
private readonly UriResolver _uriResolver;
private readonly BTCPayNetworkProvider _networkProvider;
- private readonly WalletRepository _walletRepository;
+ private readonly StoreLabelRepository _storeLabelRepository;
+
private FormComponentProviders FormProviders { get; }
public FormDataService FormDataService { get; }
@@ -71,7 +72,7 @@ namespace BTCPayServer.Controllers
IStringLocalizer stringLocalizer,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider,
- WalletRepository walletRepository)
+ StoreLabelRepository storeLabelRepository)
{
_InvoiceController = invoiceController;
_handlers = handlers;
@@ -86,9 +87,9 @@ namespace BTCPayServer.Controllers
_dbContextFactory = dbContextFactory;
FormProviders = formProviders;
FormDataService = formDataService;
- _networkProvider = networkProvider;
StringLocalizer = stringLocalizer;
- _walletRepository = walletRepository;
+ _networkProvider = networkProvider;
+ _storeLabelRepository = storeLabelRepository;
}
[HttpGet("/stores/{storeId}/payment-requests")]
@@ -98,10 +99,6 @@ namespace BTCPayServer.Controllers
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
var store = GetCurrentStore();
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
- model.WalletId = walletId.ToString();
-
var timezoneOffset = model.TimezoneOffset ?? 0;
var fs = new SearchString(model.SearchTerm, timezoneOffset);
var textSearch = model.SearchText;
@@ -112,7 +109,6 @@ namespace BTCPayServer.Controllers
{
UserId = GetUserId(),
StoreId = store.Id,
- WalletId = model.WalletId,
Skip = model.Skip,
Count = model.Count,
Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse<Client.Models.PaymentRequestStatus>(s, true)).ToArray(),
@@ -133,7 +129,7 @@ namespace BTCPayServer.Controllers
var paymentRequestIds = items.Select(i => i.Id).ToArray();
var labelsByPaymentRequestId =
- await _walletRepository.GetWalletLabelsForObjects(walletId, WalletObjectData.Types.PaymentRequest, paymentRequestIds);
+ await _storeLabelRepository.GetStoreLabelsForObjects(store.Id, WalletObjectData.Types.PaymentRequest, paymentRequestIds);
foreach (var item in items)
{
@@ -152,7 +148,7 @@ namespace BTCPayServer.Controllers
}
}
- var allLabels = await _walletRepository.GetWalletLabelsByLinkedType(walletId, WalletObjectData.Types.PaymentRequest);
+ var allLabels = await _storeLabelRepository.GetStoreLabels(store.Id, WalletObjectData.Types.PaymentRequest);
model.Labels = allLabels
.Select(l => new TransactionTagModel
{
@@ -199,15 +195,13 @@ namespace BTCPayServer.Controllers
vm.Currency ??= storeBlob.DefaultCurrency;
vm.HasEmailRules = await HasEmailRules(store.Id);
- if (!string.IsNullOrEmpty(payReqId))
+ if (string.IsNullOrEmpty(payReqId))
+ return View(nameof(EditPaymentRequest), vm);
+
+ var labels = await _storeLabelRepository.GetStoreLabelsForObjects(store.Id, WalletObjectData.Types.PaymentRequest, new[] { payReqId });
+ if (labels.TryGetValue(payReqId, out var labelTuples))
{
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
- var labels = await _walletRepository.GetWalletLabelsForObjects(walletId, WalletObjectData.Types.PaymentRequest, new[] { payReqId });
- if (labels.TryGetValue(payReqId, out var labelTuples))
- {
- vm.Labels = labelTuples.Select(l => l.Label).ToList();
- }
+ vm.Labels = labelTuples.Select(l => l.Label).ToList();
}
return View(nameof(EditPaymentRequest), vm);
@@ -305,28 +299,11 @@ namespace BTCPayServer.Controllers
data = await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(data);
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
- var walletObjectId = new WalletObjectId(walletId, WalletObjectData.Types.PaymentRequest, data.Id);
-
- if (!isNewPaymentRequest)
- {
- var existingLabels = await _walletRepository.GetWalletLabelsForObjects(walletId, WalletObjectData.Types.PaymentRequest, new[] { data.Id });
- if (existingLabels.TryGetValue(data.Id, out var labelTuples))
- {
- var currentLabels = labelTuples.Select(l => l.Label).ToArray();
- var toRemove = currentLabels.Where(label => !viewModel.Labels.Contains(label)).ToArray();
- if (toRemove.Any())
- {
- await _walletRepository.RemoveWalletObjectLabels(walletObjectId, toRemove);
- }
- }
- }
-
- if (viewModel.Labels.Any())
- {
- await _walletRepository.AddWalletObjectLabels(walletObjectId, viewModel.Labels.ToArray());
- }
+ await _storeLabelRepository.SetStoreObjectLabels(
+ store.Id,
+ WalletObjectData.Types.PaymentRequest,
+ data.Id,
+ viewModel.Labels?.ToArray() ?? Array.Empty<string>());
TempData[WellKnownTempData.SuccessMessage] = isNewPaymentRequest
? StringLocalizer["Payment request \"{0}\" created successfully", viewModel.Title].Value
@@ -611,11 +588,7 @@ namespace BTCPayServer.Controllers
[Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> PaymentRequestLabels(string storeId)
{
- var store = GetCurrentStore();
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
-
- var labels = await _walletRepository.GetWalletLabelsByLinkedType(walletId, WalletObjectData.Types.PaymentRequest);
+ var labels = await _storeLabelRepository.GetStoreLabels(storeId, WalletObjectData.Types.PaymentRequest);
var vm = new PaymentRequestLabelsViewModel
{
@@ -638,19 +611,24 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> DeletePaymentRequestLabel(string storeId, string id)
{
var store = GetCurrentStore();
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
- var labels = new[] { id };
-
- if (await _walletRepository.RemoveWalletLabels(walletId, labels))
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully deleted."].Value;
- }
- else
+ if (store is null || store.Id != storeId)
+ return NotFound();
+
+ if (WalletObjectData.Types.AllTypes.Contains(id))
{
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be deleted."].Value;
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["This label cannot be deleted."].Value;
+ return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
}
+ var ok = await _storeLabelRepository.RemoveStoreLabels(
+ storeId,
+ WalletObjectData.Types.PaymentRequest,
+ new[] { id });
+
+ TempData[WellKnownTempData.SuccessMessage] = ok
+ ? StringLocalizer["The label has been successfully deleted."].Value
+ : StringLocalizer["The label could not be deleted."].Value;
+
return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
}
@@ -666,23 +644,28 @@ namespace BTCPayServer.Controllers
newLabel = newLabel.Trim();
if (newLabel == id)
- {
return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
- }
var store = GetCurrentStore();
- var defaultNetwork = _networkProvider.DefaultNetwork;
- var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
+ if (store is null || store.Id != storeId)
+ return NotFound();
- if (await _walletRepository.RenameWalletLabel(walletId, id, newLabel))
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully renamed."].Value;
- }
- else
+ if (WalletObjectData.Types.AllTypes.Contains(id) || WalletObjectData.Types.AllTypes.Contains(newLabel))
{
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be renamed."].Value;
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["This label cannot be renamed."].Value;
+ return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
}
+ var ok = await _storeLabelRepository.RenameStoreLabel(
+ storeId,
+ WalletObjectData.Types.PaymentRequest,
+ id,
+ newLabel);
+
+ TempData[WellKnownTempData.SuccessMessage] = ok
+ ? StringLocalizer["The label has been successfully renamed."].Value
+ : StringLocalizer["The label could not be renamed."].Value;
+
return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
}
diff --git a/BTCPayServer/Controllers/UIStoresController.Labels.cs b/BTCPayServer/Controllers/UIStoresController.Labels.cs
new file mode 100644
index 0000000..a724cf6
--- /dev/null
+++ b/BTCPayServer/Controllers/UIStoresController.Labels.cs
@@ -0,0 +1,66 @@
+#nullable enable
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Models.WalletViewModels;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Controllers;
+
+public partial class UIStoresController
+{
+
+ [HttpGet("{storeId}/labels.json")]
+ [IgnoreAntiforgeryToken]
+ public async Task<IActionResult> StoreLabelsJson(
+ string storeId,
+ bool excludeTypes = true,
+ string? linkedType = null)
+ {
+ var store = CurrentStore;
+ if (store is null || !string.Equals(store.Id, storeId, StringComparison.Ordinal))
+ return NotFound();
+
+ if (string.IsNullOrEmpty(linkedType))
+ return BadRequest("linkedType is required.");
+
+ var labels = await _storeLabelRepository.GetStoreLabels(storeId, linkedType);
+
+ return Ok(labels
+ .Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
+ .Select(l => new WalletLabelModel
+ {
+ Label = l.Label,
+ Color = l.Color,
+ TextColor = ColorPalette.Default.TextColor(l.Color)
+ }));
+ }
+
+ public class UpdateStoreLabelsRequest
+ {
+ public string? Type { get; set; }
+ public string? Id { get; set; }
+ public string[]? Labels { get; set; }
+ }
+
+ [HttpPost("{storeId}/update-labels")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> UpdateStoreLabels(string storeId, [FromBody] UpdateStoreLabelsRequest request)
+ {
+ var store = CurrentStore;
+ if (store is null || !string.Equals(store.Id, storeId, StringComparison.Ordinal))
+ return NotFound();
+
+ if (string.IsNullOrWhiteSpace(request.Type) || string.IsNullOrWhiteSpace(request.Id))
+ return BadRequest();
+
+ await _storeLabelRepository.SetStoreObjectLabels(storeId, request.Type, request.Id, request.Labels ?? Array.Empty<string>());
+
+ return Ok();
+ }
+}
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index 32f1ff2..c2eb809 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -12,6 +12,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
@@ -63,7 +64,8 @@ public partial class UIStoresController : Controller
IStringLocalizer stringLocalizer,
EventAggregator eventAggregator,
LightningHistogramService lnHistogramService,
- LightningClientFactoryService lightningClientFactory)
+ LightningClientFactoryService lightningClientFactory,
+ StoreLabelRepository storeLabelRepository)
{
_rateFactory = rateFactory;
_storeRepo = storeRepo;
@@ -96,6 +98,7 @@ public partial class UIStoresController : Controller
_lnHistogramService = lnHistogramService;
_lightningClientFactory = lightningClientFactory;
StringLocalizer = stringLocalizer;
+ _storeLabelRepository = storeLabelRepository;
}
private readonly BTCPayServerOptions _btcpayServerOptions;
@@ -128,6 +131,7 @@ public partial class UIStoresController : Controller
private readonly IDataProtector _dataProtector;
private readonly LightningHistogramService _lnHistogramService;
private readonly LightningClientFactoryService _lightningClientFactory;
+ private readonly StoreLabelRepository _storeLabelRepository;
public string? GeneratedPairingCode { get; set; }
public IStringLocalizer StringLocalizer { get; }
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index 3114fec..54e0981 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -1876,14 +1876,17 @@ namespace BTCPayServer.Controllers
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
bool excludeTypes,
string? type = null,
- string? id = null)
+ string? id = null,
+ string? linkedType = null)
{
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
? new WalletObjectId(walletId, type, id)
: null;
- var labels = walletObjectId == null
- ? await WalletRepository.GetWalletLabels(walletId)
- : await WalletRepository.GetWalletLabels(walletObjectId);
+ var labels = walletObjectId != null
+ ? await WalletRepository.GetWalletLabels(walletObjectId)
+ : !string.IsNullOrEmpty(linkedType)
+ ? await WalletRepository.GetWalletLabelsByLinkedType(walletId, linkedType)
+ : await WalletRepository.GetWalletLabels(walletId);
return Ok(labels
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new WalletLabelModel
@@ -1923,7 +1926,7 @@ namespace BTCPayServer.Controllers
WalletId walletId, string id)
{
var labels = new[] { id };
-
+
if (await WalletRepository.RemoveWalletLabels(walletId, labels))
{
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully deleted."].Value;
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 8d1ba83..c044de7 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -367,6 +367,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<NBXplorerDashboard>();
services.AddSingleton<ISyncSummaryProvider, NBXSyncSummaryProvider>();
services.TryAddSingleton<StoreRepository>();
+ services.TryAddSingleton<StoreLabelRepository>();
services.TryAddSingleton<PaymentRequestRepository>();
services.TryAddSingleton<BTCPayWalletProvider>();
services.AddSingleton<PendingTransactionService>();
diff --git a/BTCPayServer/Services/Labels/StoreLabelRepository.cs b/BTCPayServer/Services/Labels/StoreLabelRepository.cs
new file mode 100644
index 0000000..9d7bede
--- /dev/null
+++ b/BTCPayServer/Services/Labels/StoreLabelRepository.cs
@@ -0,0 +1,370 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Dapper;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace BTCPayServer.Services.Labels;
+
+public class StoreLabelRepository
+{
+ private readonly ApplicationDbContextFactory _contextFactory;
+
+ public StoreLabelRepository(ApplicationDbContextFactory contextFactory)
+ {
+ _contextFactory = contextFactory;
+ }
+
+ public async Task<(string Label, string Color)[]> GetStoreLabels(string storeId, string type)
+ {
+ await using var ctx = _contextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+
+ var rows = (await conn.QueryAsync<(string Text, string? Color)>(
+ """
+ SELECT text, color
+ FROM store_labels
+ WHERE store_id = @storeId
+ AND type = @type
+ ORDER BY text
+ """, new { storeId, type })).ToList();
+
+ return rows.Select(r => FormatToLabel(r.Text, r.Color)).ToArray();
+ }
+
+ public async Task<Dictionary<string, (string Label, string Color)[]>> GetStoreLabelsForObjects(
+ string storeId,
+ string type,
+ string[]? objectIds)
+ {
+ objectIds ??= Array.Empty<string>();
+ objectIds = objectIds
+ .Where(o => !string.IsNullOrWhiteSpace(o))
+ .Select(o => o.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (objectIds.Length == 0)
+ return new Dictionary<string, (string Label, string Color)[]>(StringComparer.Ordinal);
+
+ await using var ctx = _contextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+
+ var rows = await conn.QueryAsync<(string ObjectId, string Text, string? Color)>(
+ """
+ SELECT sll.object_id, sl.text, sl.color
+ FROM store_label_links sll
+ INNER JOIN store_labels sl
+ ON sl.store_id = sll.store_id
+ AND sl.id = sll.store_label_id
+ WHERE sll.store_id = @storeId
+ AND sll.object_id = ANY(@objectIds)
+ AND sl.type = @type
+ ORDER BY sll.object_id, sl.text
+ """,
+ new { storeId, type, objectIds });
+
+ var dict = new Dictionary<string, List<(string Label, string Color)>>(StringComparer.Ordinal);
+
+ foreach (var r in rows)
+ {
+ if (!dict.TryGetValue(r.ObjectId, out var list))
+ {
+ list = new List<(string Label, string Color)>();
+ dict.Add(r.ObjectId, list);
+ }
+
+ list.Add(FormatToLabel(r.Text, r.Color));
+ }
+
+ return dict.ToDictionary(k => k.Key, v => v.Value.ToArray(), StringComparer.Ordinal);
+ }
+
+ public async Task SetStoreObjectLabels(string storeId, string type, string objectId, string[] labels)
+ {
+ var desired = labels
+ .Select(l => string.IsNullOrEmpty(l) ? string.Empty : NormalizeLabel(l))
+ .Where(l => l.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ await using var ctx = _contextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ var dbTx = tx.GetDbTransaction();
+
+ var currentTexts = (await conn.QueryAsync<string>(
+ """
+ SELECT sl.text
+ FROM store_label_links sll
+ INNER JOIN store_labels sl
+ ON sl.store_id = sll.store_id
+ AND sl.id = sll.store_label_id
+ WHERE sll.store_id = @storeId
+ AND sll.object_id = @objectId
+ AND sl.type = @type
+ """, new { storeId, type, objectId }, transaction: dbTx))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var toAddTexts = desired.Where(t => !currentTexts.Contains(t)).ToArray();
+ var toRemoveTexts = currentTexts.Where(t => !desired.Contains(t, StringComparer.OrdinalIgnoreCase)).ToArray();
+
+ if (toAddTexts.Length == 0 && toRemoveTexts.Length == 0)
+ {
+ await tx.CommitAsync();
+ return;
+ }
+
+ if (toRemoveTexts.Length > 0)
+ {
+ var toRemoveIds = (await conn.QueryAsync<string>(
+ """
+ SELECT id
+ FROM store_labels
+ WHERE store_id = @storeId
+ AND type = @type
+ AND text = ANY(@toRemoveTexts)
+ """, new { storeId, type, toRemoveTexts }, transaction: dbTx)).ToArray();
+
+ if (toRemoveIds.Length > 0)
+ {
+ await conn.ExecuteAsync(
+ """
+ DELETE FROM store_label_links
+ WHERE store_id = @storeId
+ AND object_id = @objectId
+ AND store_label_id = ANY(@toRemoveIds)
+ """, new { storeId, objectId, toRemoveIds }, transaction: dbTx);
+ }
+ }
+
+ if (toAddTexts.Length > 0)
+ {
+ var labelIdByText = await EnsureTypedLabelsExist(conn, dbTx, storeId, type, toAddTexts);
+ var toAddIds = toAddTexts.Select(t => labelIdByText[t]).ToArray();
+
+ await conn.ExecuteAsync(
+ """
+ INSERT INTO store_label_links (store_id, store_label_id, object_id)
+ SELECT @storeId, unnest(@toAddIds), @objectId
+ ON CONFLICT (store_id, store_label_id, object_id) DO NOTHING
+ """, new { storeId, objectId, toAddIds }, transaction: dbTx);
+ }
+
+ await tx.CommitAsync();
+ }
+
+ public async Task<bool> RemoveStoreLabels(string storeId, string type, string[] labels)
+ {
+ var normalized = labels
+ .Select(l => string.IsNullOrEmpty(l) ? string.Empty : NormalizeLabel(l))
+ .Where(l => l.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (normalized.Length == 0)
+ return false;
+
+ var lowerNormalized = normalized.Select(n => n.ToLowerInvariant()).ToArray();
+ await using var ctx = _contextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+
+ var affected = await conn.ExecuteAsync(
+ """
+ DELETE FROM store_labels sl
+ USING unnest(@lowerNormalized) AS t(lower_text)
+ WHERE sl.store_id = @storeId
+ AND sl.type = @type
+ AND lower(sl.text) = t.lower_text
+ """, new { storeId, type, lowerNormalized });
+
+ return affected > 0;
+ }
+
+ public async Task<bool> RenameStoreLabel(string storeId, string type, string oldLabel, string newLabel)
+ {
+ oldLabel = NormalizeLabel(oldLabel);
+ newLabel = NormalizeLabel(newLabel);
+
+ if (string.IsNullOrEmpty(oldLabel) || string.IsNullOrEmpty(newLabel))
+ return false;
+
+ await using var ctx = _contextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+ await using var tx = await ctx.Database.BeginTransactionAsync();
+ var dbTx = tx.GetDbTransaction();
+
+ var oldLabelLower = oldLabel.ToLowerInvariant();
+ var newLabelLower = newLabel.ToLowerInvariant();
+ var oldId = await conn.QuerySingleOrDefaultAsync<string?>(
+ """
+ SELECT id
+ FROM store_labels
+ WHERE store_id = @storeId
+ AND type = @type
+ AND lower(text) = @oldLabelLower
+ """, new { storeId, type, oldLabelLower }, transaction: dbTx);
+
+ if (oldId is null)
+ return false;
+
+ if (oldLabel.Equals(newLabel, StringComparison.Ordinal))
+ return true;
+
+ var newId = await conn.QuerySingleOrDefaultAsync<string?>(
+ """
+ SELECT id
+ FROM store_labels
+ WHERE store_id = @storeId
+ AND type = @type
+ AND lower(text) = @newLabelLower
+ """, new { storeId, type, newLabelLower }, transaction: dbTx);
+
+ if (newId is null || newId == oldId)
+ {
+ // No existing label with the new text just rename the label in place
+ var updatedLabels = await conn.ExecuteAsync(
+ """
+ UPDATE store_labels
+ SET text = @newLabel
+ WHERE store_id = @storeId
+ AND id = @oldId
+ """, new { storeId, oldId, newLabel }, transaction: dbTx);
+
+ await tx.CommitAsync();
+ return updatedLabels > 0;
+ }
+
+ // Merge old label into existing new label:
+ // drop duplicate links
+ await conn.ExecuteAsync(
+ """
+ DELETE FROM store_label_links old
+ WHERE old.store_id = @storeId
+ AND old.store_label_id = @oldId
+ AND EXISTS (
+ SELECT 1
+ FROM store_label_links cur
+ WHERE cur.store_id = old.store_id
+ AND cur.object_id = old.object_id
+ AND cur.store_label_id = @newId
+ )
+ """,
+ new { storeId, oldId, newId }, transaction: dbTx);
+
+ // relink remaining old links to newId
+ await conn.ExecuteAsync(
+ """
+ UPDATE store_label_links
+ SET store_label_id = @newId
+ WHERE store_id = @storeId
+ AND store_label_id = @oldId
+ """,
+ new { storeId, oldId, newId }, transaction: dbTx);
+
+ //delete old label if it becomes orphaned.
+ await conn.ExecuteAsync(
+ """
+ DELETE FROM store_labels sl
+ WHERE sl.store_id = @storeId
+ AND sl.id = @oldId
+ AND NOT EXISTS (
+ SELECT 1
+ FROM store_label_links sll
+ WHERE sll.store_id = sl.store_id
+ AND sll.store_label_id = sl.id
+ )
+ """,
+ new { storeId, oldId }, transaction: dbTx);
+
+ await tx.CommitAsync();
+ return true;
+ }
+
+ private async Task<Dictionary<string, string>> EnsureTypedLabelsExist(
+ DbConnection conn,
+ DbTransaction dbTx,
+ string storeId,
+ string type,
+ string[] texts)
+ {
+ var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ if (texts.Length == 0)
+ return result;
+
+ var lowerTexts = texts.Select(t => t.ToLowerInvariant()).ToArray();
+ var existing = (await conn.QueryAsync<(string Id, string Text)>(
+ """
+ SELECT sl.id, sl.text
+ FROM store_labels sl
+ INNER JOIN unnest(@lowerTexts) AS t(lower_text)
+ ON sl.store_id = @storeId
+ AND sl.type = @type
+ AND lower(sl.text) = t.lower_text
+ """, new { storeId, type, lowerTexts }, transaction: dbTx)).ToList();
+
+ foreach (var e in existing)
+ result[e.Text] = e.Id;
+
+ var missing = texts.Where(t => !result.ContainsKey(t)).ToArray();
+ if (missing.Length > 0)
+ {
+ var lowerMissing = missing.Select(m => m.ToLowerInvariant()).ToArray();
+ var rows = missing.Select(t => new
+ {
+ StoreId = storeId,
+ Id = Guid.NewGuid().ToString(),
+ Type = type,
+ Text = t,
+ Color = ColorPalette.Default.DeterministicColor(t)
+ }).ToArray();
+
+ await conn.ExecuteAsync(
+ """
+ INSERT INTO store_labels (store_id, id, type, text, color)
+ VALUES (@StoreId, @Id, @Type, @Text, @Color)
+ ON CONFLICT (store_id, type, lower(text)) DO UPDATE
+ SET color = COALESCE(store_labels.color, EXCLUDED.color)
+ """,
+ rows,
+ transaction: dbTx);
+
+ var inserted = await conn.QueryAsync<(string Id, string Text)>(
+ """
+ SELECT sl.id, sl.text
+ FROM store_labels sl
+ INNER JOIN unnest(@lowerMissing) AS t(lower_text)
+ ON sl.store_id = @storeId
+ AND sl.type = @type
+ AND lower(sl.text) = t.lower_text
+ """, new { storeId, type, lowerMissing }, transaction: dbTx);
+
+ foreach (var i in inserted)
+ result[i.Text] = i.Id;
+ }
+
+ return result;
+ }
+
+ private static (string Label, string Color) FormatToLabel(string text, string? color)
+ {
+ return !string.IsNullOrEmpty(color) ? (text, color) : (text, ColorPalette.Default.DeterministicColor(text));
+ }
+
+ private const int MaxLabelSize = 50;
+
+ private static string NormalizeLabel(string label)
+ {
+ label = label.Trim();
+ if (label.Length > MaxLabelSize)
+ label = label[..MaxLabelSize];
+ return label;
+ }
+
+}
diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
index 664395a..abf1867 100644
--- a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
+++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
@@ -147,10 +147,10 @@ namespace BTCPayServer.Services.PaymentRequests
// Escape LIKE wildcards to prevent SQL injection
var escapedSearch = search.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
var likePattern = $"%{escapedSearch}%";
- var amountOrNull = decimal.TryParse(search, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount)
- ? amount
+ var amountOrNull = decimal.TryParse(search, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount)
+ ? amount
: (decimal?)null;
-
+
queryable = context.PaymentRequests
.Where(a => a.StoreDataId == query.StoreId)
.Where(a =>
@@ -174,23 +174,13 @@ namespace BTCPayServer.Services.PaymentRequests
{
if (string.IsNullOrEmpty(query.StoreId))
throw new InvalidOperationException("PaymentRequestQuery.StoreId should be specified for label filtering");
- if (string.IsNullOrEmpty(query.WalletId))
- throw new InvalidOperationException("PaymentRequestQuery.WalletId should be specified for label filtering");
-
- var paymentRequestsIds = await context.WalletObjectLinks
- .Where(l =>
- l.WalletId == query.WalletId &&
- l.AType == WalletObjectData.Types.Label &&
- l.BType == WalletObjectData.Types.PaymentRequest &&
- l.AId == query.LabelFilter)
- .Select(l => l.BId)
- .Distinct()
- .ToArrayAsync(cancellationToken);
-
- if (paymentRequestsIds.Length == 0)
- return Array.Empty<PaymentRequestData>();
-
- queryable = queryable.Where(paymentRequest => paymentRequestsIds.Contains(paymentRequest.Id));
+
+ queryable = queryable.Where(pr =>
+ context.StoreLabelLinks.Any(l =>
+ l.StoreId == query.StoreId &&
+ l.ObjectId == pr.Id &&
+ l.StoreLabel.Type == WalletObjectData.Types.PaymentRequest &&
+ l.StoreLabel.Text == query.LabelFilter.Trim()));
}
queryable = queryable.Include(data => data.StoreData);
@@ -269,7 +259,6 @@ namespace BTCPayServer.Services.PaymentRequests
public class PaymentRequestQuery
{
public string StoreId { get; set; }
- public string WalletId { get; set; }
public bool IncludeArchived { get; set; } = true;
public PaymentRequestStatus[] Status { get; set; }
public string UserId { get; set; }
diff --git a/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs b/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs
index 8fca59e..f567383 100644
--- a/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs
+++ b/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs
@@ -6,6 +6,7 @@ using BTCPayServer.Data;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Labels;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.EntityFrameworkCore;
@@ -15,8 +16,7 @@ public class PaymentRequestsReportProvider(
ApplicationDbContextFactory dbContextFactory,
InvoiceRepository invoiceRepository,
PaymentMethodHandlerDictionary handlers,
- WalletRepository walletRepository,
- BTCPayNetworkProvider networkProvider,
+ StoreLabelRepository storeLabelRepository,
DisplayFormatter displayFormatter)
: ReportProvider
{
@@ -80,9 +80,6 @@ public class PaymentRequestsReportProvider(
if (paymentRequests.Count == 0)
return;
- var network = networkProvider.DefaultNetwork;
- var walletId = new WalletId(queryContext.StoreId, network.CryptoCode);
-
var paymentRequestIds = paymentRequests.Select(pr => pr.Id).ToArray();
var orderIds = paymentRequests
@@ -90,8 +87,8 @@ public class PaymentRequestsReportProvider(
.Distinct()
.ToArray();
- var labelsTask = walletRepository.GetWalletLabelsForObjects(
- walletId,
+ var labelsTask = storeLabelRepository.GetStoreLabelsForObjects(
+ queryContext.StoreId,
WalletObjectData.Types.PaymentRequest,
paymentRequestIds
);
@@ -115,13 +112,12 @@ public class PaymentRequestsReportProvider(
foreach (var paymentRequest in paymentRequests)
{
- var prBlob = paymentRequest.GetBlob();
var prOrderId = PaymentRequestRepository.GetOrderIdForPaymentRequest(paymentRequest.Id);
labelsByPaymentRequestId.TryGetValue(paymentRequest.Id, out var labelTuples);
var labelsString = labelTuples is { Length: > 0 }
? string.Join(", ", labelTuples.Select(l => l.Label))
- : "";
+ : "Unlabeled";
if (!invoicesByOrderId.TryGetValue(prOrderId, out var prInvoices) || prInvoices.Count == 0)
{
diff --git a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml
index 1eb2618..d883f10 100644
--- a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml
@@ -114,11 +114,13 @@
}
</select>
<vc:label-manager
- selected-labels="Model.Labels.ToArray()"
+ selected-labels="Model.Labels?.ToArray()"
exclude-types="true"
select-element="Labels"
- wallet-object-id="new WalletObjectId(wallet, WalletObjectData.Types.PaymentRequest, Model.Id ?? string.Empty)"
- auto-update="false" />
+ auto-update="false"
+ linked-type="@WalletObjectData.Types.PaymentRequest"
+ store-id="@Model.StoreId"
+ store-object-id="@Model.Id" />
<a asp-action="PaymentRequestLabels" asp-controller="UIPaymentRequest" asp-route-storeId="@Model.StoreId"
class="btn btn-secondary input-group-clear"
title="@StringLocalizer["Manage Labels"]">
diff --git a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
index d0cf1df..aed7937 100644
--- a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
@@ -341,14 +341,13 @@
@item.ReferenceId
</td>
<td>
- @if (wallet != null)
- {
- <vc:label-manager
- wallet-object-id="new WalletObjectId(wallet, WalletObjectData.Types.PaymentRequest, item.Id)"
- selected-labels="item.Labels?.Select(l => l.Text).ToArray()"
- exclude-types="true"
- display-inline="true" />
- }
+ <vc:label-manager
+ selected-labels="item.Labels?.Select(l => l.Text).ToArray()"
+ exclude-types="true"
+ display-inline="true"
+ linked-type="@WalletObjectData.Types.PaymentRequest"
+ store-id="@storeId"
+ store-object-id="@item.Id"/>
</td>
<td>
<span class="badge badge-@item.Status.ToLower() status-badge">@item.Status</span>
diff --git a/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
index 50bc1b4..0074e42 100644
--- a/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
+++ b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
@@ -149,15 +149,13 @@
const labelManagerList = document.querySelectorAll("input.label-manager");
labelManagerList.forEach(labelManager => {
labelManager.addEventListener("labelmanager:changed", ({ detail }) => {
- const { walletObjectId, labels: newLabels } = detail;
+ const { id, labels: newLabels } = detail;
- const targetAddress = this.addresses.find(addr => addr.address === walletObjectId);
+ const targetAddress = this.addresses.find(addr => addr.address === id);
if (!targetAddress) return;
- const existingLabels = targetAddress.labels?.map(l => l.text) || [];
- const merged = Array.from(new Set([...existingLabels, ...newLabels])).map(text => ({ text }));
-
- this.$set(targetAddress, 'labels', merged);
+ const next = (newLabels || []).map(text => ({ text }));
+ this.$set(targetAddress, "labels", next);
});
});
},
diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js
index 518aa71..9f88dd3 100644
--- a/BTCPayServer/wwwroot/main/site.js
+++ b/BTCPayServer/wwwroot/main/site.js
@@ -17,9 +17,26 @@ async function initLabelManager (elementId) {
const element = document.getElementById(elementId);
if (!element) return;
- const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels, selectElement } = element.dataset;
- const commonCallId = `walletLabels-${walletId}`;
+ const {
+ fetchUrl,
+ updateUrl,
+ walletId,
+ labels,
+ selectElement,
+ storeId,
+ objectId,
+ objectType
+ } = element.dataset;
+
+ const isStoreScoped = !!storeId;
+
+ const commonCallId = isStoreScoped
+ ? `labels-store-${storeId}-${objectType}`
+ : `labels-wallet-${walletId}-${objectType}`;
+
const fetchWalletLabels = async (force = false) => {
+ if (!fetchUrl) return [];
+
if (!force && window[commonCallId])
return window[commonCallId];
@@ -163,8 +180,9 @@ async function initLabelManager (elementId) {
const labels = Array.isArray(values) ? values : values.split(',');
element.dispatchEvent(new CustomEvent("labelmanager:changed", {
detail: {
- walletObjectId,
- labels: labels
+ id: objectId,
+ type: objectType,
+ labels
}
}));
@@ -180,16 +198,16 @@ async function initLabelManager (elementId) {
if (!updateUrl) return;
select.lock();
try {
+ const payload = { id: objectId, type: objectType, labels: select.items };
+ const tokenInput =
+ element.closest('form')?.querySelector('input[name="__RequestVerificationToken"]') ||
+ document.querySelector('input[name="__RequestVerificationToken"]');
+ const headers = { 'Content-Type': 'application/json' };
+ if (tokenInput?.value) headers['RequestVerificationToken'] = tokenInput.value;
const response = await fetch(updateUrl, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- id: walletObjectId,
- type: walletObjectType,
- labels: select.items
- })
+ headers,
+ body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Network response was not OK');
Why this scored 29/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.