feat(wallets): add transaction search and date filters. (#7133)
What changed, and why it matters
This commit adds new search and filter features to the wallet transaction list in BTCPay Server. Users can now filter transactions by text, direction (incoming/outgoing), labels, and date ranges, and export results matching those filters. The change is a feature addition with a large amount of new code, but the diff itself does not show an obvious security vulnerability. There are a few minor hardening touches, such as adding a missing null check for an unknown cryptocurrency code and removing a redundant anti-forgery attribute.
Treat this as a normal feature commit, not a security fix. Reviewers should verify that user-supplied SearchTerm/SearchText values are properly sanitized/encoded in URLs and that in-memory filtering cannot be used to leak transactions across store or wallet boundaries. The export endpoint's new parameters should be checked for authorization enforcement. No immediate patching is indicated by the diff alone.
Security signals we found
New user-controlled search/filter parsing added to controller
SearchString tokens now include label, nolabel, and direction
In-memory filtering of transaction metadata after DB fetch
Export endpoint now accepts searchTerm, searchText, timezoneOffset parameters
Added null check for unknown network in pending transaction action
Removed redundant antiforgery attribute
Evidence from the diff
The patch introduces UIWalletsController.BuildWalletTransactionsFilter and related helpers to parse a structured SearchString into label, direction, date, and free-text filters for wallet transactions. It updates WalletTransactions and Export actions to apply these filters either at the data source or in-memory, and adds a responsive Razor view with dropdowns for direction, labels, dates, search text, and clear-all. SearchString.cs is updated so wallet-specific tokens (label, nolabel, direction) are stripped from invoice-style search text. Tests cover filter parsing, URL preservation, and export link state. A small null guard is added for an unrecognized wallet crypto code, and a redundant [ValidateAntiForgeryToken] attribute is removed.
Changed components
BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.csBTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtmlBTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.csBTCPayServer/SearchString.csBTCPayServer/Extensions.csInspect captured patch +1383 / −138
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index a0b0be2..17b1623 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -1458,6 +1458,16 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Equal("startdate:2019-04-25 01:00 AM", search.WithoutSearchText());
Assert.Equal(filter, search.ToString());
+ filter = "label:test,nolabel:true,direction:in, hekki";
+ search = new SearchString(filter);
+ Assert.Equal("hekki", search.TextSearch);
+ Assert.Null(search.TextFilters);
+ Assert.Equal("hekki", search.TextCombined);
+ Assert.Equal("label:test,nolabel:true,direction:in", search.WithoutSearchText());
+ Assert.Single(search.Filters["label"], "test");
+ Assert.Single(search.Filters["direction"], "in");
+ Assert.True(search.GetFilterBool("nolabel"));
+
// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
search = new SearchString(filter);
@@ -1504,6 +1514,61 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Equal("startdate:-7d", search.WithoutSearchText());
}
+ [Fact]
+ public void BuildWalletTransactionsFilterSeparatesTextAndStructuredTerms()
+ {
+ var result = UIWalletsController.BuildWalletTransactionsFilter(
+ "direction:out,label:primary-label,nolabel:true,startdate:2026-03-01T12:34:56",
+ "abc123tx",
+ "secondary-label",
+ 120);
+
+ Assert.Equal("abc123tx", result.SearchInputText);
+ Assert.Equal("abc123tx", result.SearchText);
+ Assert.Equal("abc123tx", result.TextSearch);
+
+ var structuredSearchTerm = result.SearchTerm;
+ Assert.Contains("direction:out", structuredSearchTerm);
+ Assert.Contains("label:primary-label", structuredSearchTerm);
+ Assert.Contains("nolabel:true", structuredSearchTerm);
+ Assert.Contains("startdate:2026-03-01T12:34:56", structuredSearchTerm);
+ Assert.DoesNotContain("abc123tx", structuredSearchTerm);
+
+ Assert.Equal(["primary-label", "secondary-label"], result.LabelFilters);
+ Assert.True(result.IncludeNoLabel);
+ Assert.False(result.Positive);
+ Assert.NotNull(result.StartDate);
+ Assert.True(result.HasLabelFilter);
+ Assert.True(result.HasFilters);
+
+ result = UIWalletsController.BuildWalletTransactionsFilter(null, "abc", null, 120);
+
+ Assert.Equal(string.Empty, result.SearchTerm);
+ Assert.Equal("abc", result.SearchText);
+ Assert.Equal("abc", result.SearchInputText);
+ Assert.Equal("abc", result.TextSearch);
+ Assert.False(result.HasLabelFilter);
+ Assert.True(result.HasFilters);
+
+ result = UIWalletsController.BuildWalletTransactionsFilter("foo:bar", null, null, 120);
+
+ Assert.Equal(string.Empty, result.SearchTerm);
+ Assert.Equal(string.Empty, result.SearchText);
+ Assert.Equal(string.Empty, result.SearchInputText);
+ Assert.Equal(string.Empty, result.TextSearch);
+ Assert.False(result.HasLabelFilter);
+ Assert.False(result.HasFilters);
+
+ result = UIWalletsController.BuildWalletTransactionsFilter(string.Empty, null, null, 120);
+
+ Assert.Equal(string.Empty, result.SearchTerm);
+ Assert.Equal(string.Empty, result.SearchText);
+ Assert.Equal(string.Empty, result.SearchInputText);
+ Assert.Equal(string.Empty, result.TextSearch);
+ Assert.False(result.HasLabelFilter);
+ Assert.False(result.HasFilters);
+ }
+
[Fact]
public void CanParseFingerprint()
{
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index b69d77e..dc2764d 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -474,6 +474,24 @@ namespace BTCPayServer.Tests
Assert.Contains("test", tx.Tags.Select(l => l.Text));
Assert.DoesNotContain("test2", tx.Tags.Select(l => l.Text));
Assert.Single(tx.Tags.GroupBy(l => l.Color));
+
+ var searchText = tx.Id[..12];
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id, "label:test", null);
+ AssertSearchWalletTransaction(walletController, walletId, false, tx.Id, "nolabel:true", null);
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id, "direction:in", null);
+ AssertSearchWalletTransaction(walletController, walletId, false, tx.Id, "direction:out", null);
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id, null, searchText);
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id, null, "hello");
+
+ var txTimestamp = tx.Timestamp;
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id,
+ $"startdate:{txTimestamp.ToString("o", CultureInfo.InvariantCulture)}", null);
+ AssertSearchWalletTransaction(walletController, walletId, true, tx.Id,
+ $"enddate:{txTimestamp.ToString("o", CultureInfo.InvariantCulture)}", null);
+ AssertSearchWalletTransaction(walletController, walletId, false, tx.Id,
+ $"startdate:{txTimestamp.AddSeconds(1).ToString("o", CultureInfo.InvariantCulture)}", null);
+ AssertSearchWalletTransaction(walletController, walletId, false, tx.Id,
+ $"enddate:{txTimestamp.AddSeconds(-1).ToString("o", CultureInfo.InvariantCulture)}", null);
}
[Fact(Timeout = LongRunningTestTimeout)]
@@ -593,6 +611,19 @@ namespace BTCPayServer.Tests
Assert.Equal(expected, result.Invoices.Any(i => i.InvoiceId == invoiceId));
}
+ private void AssertSearchWalletTransaction(UIWalletsController walletController, WalletId walletId, bool expected, string transactionId, string searchTerm, string searchText)
+ {
+ var model = new ListTransactionsViewModel
+ {
+ SearchTerm = searchTerm,
+ SearchText = searchText,
+ Count = 50
+ };
+ var result = (ListTransactionsViewModel)((ViewResult)walletController
+ .WalletTransactions(walletId, loadTransactions: true, model: model).Result).Model;
+ Assert.Equal(expected, result.Transactions.Any(tx => tx.Id == transactionId));
+ }
+
// [Fact(Timeout = TestTimeout)]
[Fact()]
[Trait("Integration", "Integration")]
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index f6dbd4b..19a006a 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using System.Web;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -404,8 +405,10 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await using (_ = await s.SwitchPage(opening))
{
await s.Page.WaitForLoadStateAsync();
+ var exportUri = new Uri(s.Page.Url);
Assert.Contains(s.WalletId.ToString(), s.Page.Url);
- Assert.EndsWith("export?format=json", s.Page.Url);
+ Assert.EndsWith("/export", exportUri.AbsolutePath);
+ Assert.Contains("format=json", exportUri.Query);
Assert.Contains("\"Amount\": \"3.00000000\"", await s.Page.ContentAsync());
}
@@ -738,7 +741,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
}
await s.GoToWalletTransactions(s.WalletId);
- await s.Page.ClickAsync("#Filter button.dropdown-toggle");
+ await s.Page.ClickAsync("#LabelOptionsToggle");
await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
await s.Page.Locator("#LabelSearch").WaitForAsync();
@@ -770,13 +773,610 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
await TestUtils.EventuallyAsync(() =>
{
- Assert.Contains($"labelFilter={targetLabel}", s.Page.Url);
+ Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(s.Page.Url));
return Task.CompletedTask;
});
await s.InWalletTransactions().AssertHasLabels(targetLabel);
}
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanPreserveSearchTextWhenApplyingWalletLabelFilter()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m));
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var transactions = Array.Empty<OnChainWalletTransactionData>();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray();
+ Assert.Equal(2, transactions.Length);
+ });
+
+ const string targetLabel = "preserve-search-label";
+ const string otherLabel = "different-wallet-label";
+ var targetSearchText = transactions[0].TransactionHash.ToString()[..12];
+
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ transactions[0].TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ transactions[1].TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { otherLabel }
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ await s.Page.FillAsync("#SearchText", targetSearchText);
+ await s.Page.PressAsync("#SearchText", "Enter");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+
+ var urlAfterSearch = new Uri(s.Page.Url);
+ var qsAfterSearch = HttpUtility.ParseQueryString(urlAfterSearch.Query);
+ Assert.Equal(targetSearchText, qsAfterSearch["SearchText"]);
+ Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"]));
+ });
+
+ await s.Page.ClickAsync("#LabelOptionsToggle");
+ await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
+ await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+ Assert.Contains($"label:{targetLabel}", await s.Page.InputValueAsync("input[name='SearchTerm']"));
+ Assert.Contains(targetLabel, await s.Page.InnerTextAsync("#LabelOptionsToggle"));
+
+ var urlAfterLabelFilter = new Uri(s.Page.Url);
+ var qsAfterLabelFilter = HttpUtility.ParseQueryString(urlAfterLabelFilter.Query);
+ Assert.Equal(targetSearchText, qsAfterLabelFilter["SearchText"]);
+ Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(qsAfterLabelFilter["SearchTerm"] ?? string.Empty));
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanPreserveSearchTextWhenApplyingWalletDatePresetFilter()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m));
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var transactions = Array.Empty<OnChainWalletTransactionData>();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray();
+ Assert.Equal(2, transactions.Length);
+ });
+
+ var targetSearchText = transactions[0].TransactionHash.ToString()[..12];
+
+ await s.GoToWalletTransactions(s.WalletId);
+ var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
+ await s.Page.FillAsync("#SearchText", targetSearchText);
+ await s.Page.PressAsync("#SearchText", "Enter");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+
+ var urlAfterSearch = new Uri(s.Page.Url);
+ var qsAfterSearch = HttpUtility.ParseQueryString(urlAfterSearch.Query);
+ Assert.Equal(targetSearchText, qsAfterSearch["SearchText"]);
+ Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"]));
+ });
+
+ await s.Page.ClickAsync("#DateOptionsToggle");
+ await s.Page.ClickAsync("#DateOptionsToggle + .dropdown-menu a:text-is('Last 24 hours')");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+ Assert.Contains("24 Hours", await s.Page.InnerTextAsync("#DateOptionsToggle"));
+
+ var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
+ Assert.Contains("startdate:-1d", hiddenSearchTerm);
+ Assert.DoesNotContain(targetSearchText, hiddenSearchTerm);
+
+ var urlAfterPreset = new Uri(s.Page.Url);
+ var qsAfterPreset = HttpUtility.ParseQueryString(urlAfterPreset.Query);
+ Assert.Equal(targetSearchText, qsAfterPreset["SearchText"]);
+ Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), qsAfterPreset["timezoneOffset"] ?? qsAfterPreset["TimezoneOffset"]);
+ Assert.Contains("startdate:-1d", Uri.UnescapeDataString(qsAfterPreset["SearchTerm"] ?? string.Empty));
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanFilterWalletTransactionsByNoLabel()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m));
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.002m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var transactions = Array.Empty<OnChainWalletTransactionData>();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray();
+ Assert.Equal(2, transactions.Length);
+ });
+
+ var labeledTx = transactions[0];
+ var unlabeledTx = transactions[1];
+ const string targetLabel = "no-label-excluded";
+
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ labeledTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ await s.Page.ClickAsync("#LabelOptionsToggle");
+ await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
+ await s.Page.ClickAsync("#LabelDropdownMenu a:text-is('No Label')");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Contains("No Label", await s.Page.InnerTextAsync("#LabelOptionsToggle"));
+ Assert.Contains("nolabel:true", await s.Page.InputValueAsync("input[name='SearchTerm']"));
+
+ var urlAfterNoLabelFilter = new Uri(s.Page.Url);
+ var qsAfterNoLabelFilter = HttpUtility.ParseQueryString(urlAfterNoLabelFilter.Query);
+ Assert.Contains("nolabel:true", Uri.UnescapeDataString(qsAfterNoLabelFilter["SearchTerm"] ?? string.Empty));
+
+ Assert.True(await s.Page.Locator($".transaction-row[data-value='{unlabeledTx.TransactionHash}']").IsVisibleAsync());
+ Assert.False(await s.Page.Locator($".transaction-row[data-value='{labeledTx.TransactionHash}']").IsVisibleAsync());
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanCombineWalletDirectionAndLabelFilters()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
+ var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest
+ {
+ Destinations =
+ [
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination
+ {
+ Destination = nodeAddress.ToString(),
+ Amount = 0.1m
+ }
+ ],
+ FeeRate = new FeeRate(5m)
+ });
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var transactions = Array.Empty<OnChainWalletTransactionData>();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray();
+ Assert.Contains(transactions, tx => tx.TransactionHash == outgoingTx.TransactionHash);
+ });
+
+ var incomingTx = transactions.Single(tx => tx.TransactionHash != outgoingTx.TransactionHash);
+ const string targetLabel = "combined-wallet-filter-target";
+
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ incomingTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ outgoingTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#LabelOptionsToggle");
+ await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
+ await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
+ Assert.Contains(targetLabel, await s.Page.InnerTextAsync("#LabelOptionsToggle"));
+
+ var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
+ Assert.Contains("direction:out", hiddenSearchTerm);
+ Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
+
+ var urlAfterCombinedFilter = new Uri(s.Page.Url);
+ var qsAfterCombinedFilter = HttpUtility.ParseQueryString(urlAfterCombinedFilter.Query);
+ var searchTerm = Uri.UnescapeDataString(qsAfterCombinedFilter["SearchTerm"] ?? string.Empty);
+ Assert.Contains("direction:out", searchTerm);
+ Assert.Contains($"label:{targetLabel}", searchTerm);
+
+ Assert.True(await s.Page.Locator($".transaction-row[data-value='{outgoingTx.TransactionHash}']").IsVisibleAsync());
+ Assert.False(await s.Page.Locator($".transaction-row[data-value='{incomingTx.TransactionHash}']").IsVisibleAsync());
+ Assert.Equal(1, await s.Page.Locator(".transaction-row").CountAsync());
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanFilterWalletTransactionsByDirectionWithoutPollutingSearchText()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
+ await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest
+ {
+ Destinations =
+ [
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination
+ {
+ Destination = nodeAddress.ToString(),
+ Amount = 0.1m
+ }
+ ],
+ FeeRate = new FeeRate(5m)
+ });
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Count() >= 2);
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
+
+ await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Contains("direction:out", await s.Page.GetAttributeAsync("input[name='SearchTerm']", "value"));
+ Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
+ Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
+ Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-danger").CountAsync() > 0);
+ Assert.Equal(0, await s.Page.Locator(".transaction-row .amount-col .text-success").CountAsync());
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanClearAllWalletTransactionFiltersAndSearchText()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
+ var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest
+ {
+ Destinations =
+ [
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination
+ {
+ Destination = nodeAddress.ToString(),
+ Amount = 0.1m
+ }
+ ],
+ FeeRate = new FeeRate(5m)
+ });
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Count() >= 2);
+ });
+
+ const string targetLabel = "clear-all-target";
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ outgoingTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ await s.Page.FillAsync("#SearchText", targetLabel);
+ await s.Page.PressAsync("#SearchText", "Enter");
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(targetLabel, await s.Page.InputValueAsync("#SearchText"));
+ Assert.Contains("direction:out", await s.Page.InputValueAsync("input[name='SearchTerm']"));
+ Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
+ Assert.Equal(1, await s.Page.Locator(".transaction-row").CountAsync());
+ Assert.Equal(1, await s.Page.Locator("#clearAllFiltersBtn").CountAsync());
+ });
+
+ await s.Page.ClickAsync("#clearAllFiltersBtn");
+ await s.Page.WaitForLoadStateAsync();
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
+ Assert.Equal(string.Empty, await s.Page.InputValueAsync("input[name='SearchTerm']"));
+ Assert.Contains("All Directions", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
+ Assert.Equal(0, await s.Page.Locator("#clearAllFiltersBtn").CountAsync());
+ Assert.True(await s.Page.Locator(".transaction-row").CountAsync() >= 2);
+ Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-danger").CountAsync() > 0);
+ Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-success").CountAsync() > 0);
+
+ var urlAfterClearAll = new Uri(s.Page.Url);
+ var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query);
+ Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"]));
+ Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchTerm"]));
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanPreserveWalletFiltersInExportLinks()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ var nodeAddress = await s.Server.ExplorerNode.GetNewAddressAsync();
+ var outgoingTx = await client.CreateOnChainTransaction(s.StoreId, "BTC", new CreateOnChainTransactionRequest
+ {
+ Destinations =
+ [
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination
+ {
+ Destination = nodeAddress.ToString(),
+ Amount = 0.1m
+ }
+ ],
+ FeeRate = new FeeRate(5m)
+ });
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var transactions = Array.Empty<OnChainWalletTransactionData>();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(2).ToArray();
+ Assert.Contains(transactions, tx => tx.TransactionHash == outgoingTx.TransactionHash);
+ });
+
+ var incomingTx = transactions.Single(tx => tx.TransactionHash != outgoingTx.TransactionHash);
+ const string targetLabel = "export-wallet-filter-target";
+ const string otherLabel = "export-wallet-filter-other";
+ var targetSearchText = outgoingTx.TransactionHash.ToString()[..12];
+
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ outgoingTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { targetLabel }
+ });
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ incomingTx.TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { otherLabel }
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), await s.Page.InputValueAsync("#TimezoneOffset"));
+ });
+
+ await s.Page.FillAsync("#SearchText", targetSearchText);
+ await s.Page.PressAsync("#SearchText", "Enter");
+ await s.Page.WaitForLoadStateAsync();
+
+ await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
+ await s.Page.WaitForLoadStateAsync();
+
+ await s.Page.ClickAsync("#LabelOptionsToggle");
+ await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
+ await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await s.Page.WaitForLoadStateAsync();
+
+ await s.Page.ClickAsync("#DateOptionsToggle");
+ await s.Page.ClickAsync("[data-bs-target='#customRangeModal']");
+ await s.Page.EvaluateAsync("() => { document.getElementById('dtpStartDate').value = '2026-03-01T12:34:56'; }");
+ await s.Page.ClickAsync("#btnCustomRangeDate");
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Contains(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+
+ var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
+ Assert.Contains("direction:out", hiddenSearchTerm);
+ Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
+ Assert.Contains("startdate:2026-03-01T12:34:56", hiddenSearchTerm);
+
+ foreach (var exportSelector in new[] { "#ExportCSV", "#ExportJSON", "#ExportBIP329" })
+ {
+ var href = await s.Page.GetAttributeAsync(exportSelector, "href");
+ Assert.False(string.IsNullOrEmpty(href));
+
+ var exportUri = new Uri(href!, UriKind.RelativeOrAbsolute);
+ if (!exportUri.IsAbsoluteUri)
+ exportUri = new Uri(new Uri(s.Page.Url), exportUri);
+
+ var qs = HttpUtility.ParseQueryString(exportUri.Query);
+ Assert.Equal(targetSearchText, qs["searchText"] ?? qs["SearchText"]);
+ Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), qs["timezoneOffset"] ?? qs["TimezoneOffset"]);
+
+ var exportSearchTerm = Uri.UnescapeDataString(qs["searchTerm"] ?? qs["SearchTerm"] ?? string.Empty);
+ Assert.Contains("direction:out", exportSearchTerm);
+ Assert.Contains($"label:{targetLabel}", exportSearchTerm);
+ Assert.Contains("startdate:2026-03-01T12:34:56", exportSearchTerm);
+ Assert.DoesNotContain(otherLabel, exportSearchTerm);
+ }
+ });
+ }
+
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanApplyWalletTransactionCustomDateFilterUsingClientTimezoneOffset()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
+ var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
+ var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.5m));
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Any());
+ });
+
+ await s.GoToWalletTransactions(s.WalletId);
+ var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), await s.Page.InputValueAsync("#TimezoneOffset"));
+ });
+
+ await s.Page.ClickAsync("#DateOptionsToggle");
+ await s.Page.ClickAsync("[data-bs-target='#customRangeModal']");
+ await s.Page.EvaluateAsync("() => { document.getElementById('dtpStartDate').value = '2026-03-01T12:34:56'; }");
+ await s.Page.ClickAsync("#btnCustomRangeDate");
+
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ Assert.Contains($"timezoneOffset={timezoneOffset}", s.Page.Url);
+ Assert.Contains("startdate:2026-03-01T12:34:56", Uri.UnescapeDataString(s.Page.Url));
+ Assert.Contains("Custom", await s.Page.InnerTextAsync("#DateOptionsToggle"));
+ Assert.Contains($"timezoneOffset={timezoneOffset}", await s.Page.GetAttributeAsync("#ExportCSV", "href"));
+ });
+ }
+
private async Task CreateInvoices(PlaywrightTester tester)
{
var client = await tester.AsTestAccount().CreateClient();
diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs
index b116ab6..bd5e6b1 100644
--- a/BTCPayServer/Extensions.cs
+++ b/BTCPayServer/Extensions.cs
@@ -824,7 +824,7 @@ namespace BTCPayServer
// Give times for extensions to switch to MainNavViewModel.Store
// or HttpContext.GetStoreDataOrNull
=> GetStoreDataOrNull(ctx) ??
- (ctx.Items["BTCPAY.NAVRENDERING"] is true ? null
+ (ctx.Items["BTCPAY.NAVRENDERING"] is true ? null!
: throw new InvalidOperationException("StoreData is not set"));
public static void SetStoreData(this HttpContext ctx, StoreData? storeData)
=> ctx.Items["BTCPAY.STOREDATA"] = storeData;
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
index 192c740..1b38fe3 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
@@ -36,6 +36,7 @@ using BTCPayServer.Plugins.Wallets;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
@@ -125,6 +126,8 @@ namespace BTCPayServer.Controllers
string pendingTransactionId)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
+ if (network is null)
+ return NotFound();
var pendingTransaction =
await pendingTransactionService.GetPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
if (pendingTransaction is null || network is null)
@@ -574,6 +577,138 @@ namespace BTCPayServer.Controllers
return View(wallets);
}
+ internal sealed class WalletTransactionsFilter
+ {
+ public SearchString Search { get; init; } = new(string.Empty);
+ public string SearchTerm { get; init; } = string.Empty;
+ public string SearchText { get; init; } = string.Empty;
+ public string SearchInputText { get; init; } = string.Empty;
+ public string TextSearch { get; init; } = string.Empty;
+ public DateTimeOffset? StartDate { get; init; }
+ public DateTimeOffset? EndDate { get; init; }
+ public IReadOnlyList<string> LabelFilters { get; init; } = Array.Empty<string>();
+ public bool IncludeNoLabel { get; init; }
+ public bool? Positive { get; init; }
+ public bool HasLabelFilter => IncludeNoLabel || LabelFilters.Count > 0;
+ public bool HasFilters => !string.IsNullOrWhiteSpace(SearchText) || StartDate is not null || EndDate is not null || HasLabelFilter || Positive is not null;
+ }
+
+ internal static WalletTransactionsFilter BuildWalletTransactionsFilter(string? searchTerm, string? searchText, string? labelFilter, int timezoneOffset)
+ {
+ var combinedSearchTerm = string.IsNullOrEmpty(searchText) ? searchTerm : $"{searchText},{searchTerm}";
+ var search = new SearchString(combinedSearchTerm, timezoneOffset);
+ var normalizedSearchTerm = search.ToString();
+ if (string.IsNullOrWhiteSpace(normalizedSearchTerm) || normalizedSearchTerm == " ")
+ {
+ search = new SearchString(string.Empty, timezoneOffset);
+ }
+ var labelFilters = new List<string>(search.GetFilterArray("label") ?? Array.Empty<string>());
+ if (!string.IsNullOrWhiteSpace(labelFilter))
+ {
+ labelFilters.Add(labelFilter);
+ }
+
+ var includeNoLabel = search.GetFilterBool("nolabel") is true;
+ labelFilters = labelFilters
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var textSearch = search.TextSearch ?? string.Empty;
+
+ var directionFilters = search.GetFilterArray("direction");
+ bool? positive = null;
+ if (directionFilters?.Any() is true)
+ {
+ var hasIncoming = directionFilters.Any(value => value.Equals("in", StringComparison.OrdinalIgnoreCase));
+ var hasOutgoing = directionFilters.Any(value => value.Equals("out", StringComparison.OrdinalIgnoreCase));
+ if (hasIncoming && !hasOutgoing)
+ {
+ positive = true;
+ }
+ else if (!hasIncoming && hasOutgoing)
+ {
+ positive = false;
+ }
+ }
+
+ return new WalletTransactionsFilter
+ {
+ Search = search,
+ SearchTerm = search.WithoutSearchText(),
+ SearchText = textSearch,
+ SearchInputText = textSearch,
+ TextSearch = textSearch,
+ StartDate = search.GetFilterDate("startdate", timezoneOffset),
+ EndDate = search.GetFilterDate("enddate", timezoneOffset),
+ LabelFilters = labelFilters,
+ IncludeNoLabel = includeNoLabel,
+ Positive = positive
+ };
+ }
+
+ private static bool MatchesWalletTransactionFilter(
+ TransactionHistoryLine tx,
+ WalletTransactionInfo? transactionInfo,
+ IReadOnlyCollection<string> labels,
+ IReadOnlyCollection<TransactionTagModel> tags,
+ WalletTransactionsFilter filter,
+ BTCPayNetwork network)
+ {
+ if (!MatchesWalletTransactionBasicFilter(tx, filter, network))
+ return false;
+
+ if (filter.HasLabelFilter)
+ {
+ var hasLabels = labels.Count > 0;
+ var matchesLabel = (filter.IncludeNoLabel && !hasLabels) ||
+ (filter.LabelFilters.Count > 0 && labels.Any(label => filter.LabelFilters.Any(filterLabel => filterLabel.Equals(label, StringComparison.OrdinalIgnoreCase))));
+ if (!matchesLabel)
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(filter.TextSearch))
+ {
+ var searchText = filter.TextSearch;
+ var matchesSearch =
+ tx.TransactionId.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
+ (transactionInfo?.Comment?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) ||
+ labels.Any(label => label.Contains(searchText, StringComparison.OrdinalIgnoreCase)) ||
+ tags.Any(tag => tag.Text.Contains(searchText, StringComparison.OrdinalIgnoreCase)) ||
+ (transactionInfo?.Attachments.Any(attachment =>
+ attachment.Id.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
+ attachment.Type.Contains(searchText, StringComparison.OrdinalIgnoreCase)) ?? false) ||
+ (transactionInfo?.Attachments.Any(attachment =>
+ attachment.Data?["title"]?.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) ?? false) ||
+ (transactionInfo?.Attachments.Any(attachment =>
+ attachment.Data?["text"]?.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) ?? false);
+ if (!matchesSearch)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool MatchesWalletTransactionBasicFilter(
+ TransactionHistoryLine tx,
+ WalletTransactionsFilter filter,
+ BTCPayNetwork network)
+ {
+ if (filter.StartDate is { } startDate && tx.SeenAt < startDate)
+ return false;
+ if (filter.EndDate is { } endDate && tx.SeenAt > endDate)
+ return false;
+ if (filter.Positive is not null)
+ {
+ var positive = tx.BalanceChange.GetValue(network) >= 0;
+ if (positive != filter.Positive.Value)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool RequiresWalletTransactionMetadataFiltering(WalletTransactionsFilter filter)
+ => filter.HasLabelFilter || !string.IsNullOrWhiteSpace(filter.TextSearch);
+
[HttpGet("{walletId}")]
[HttpGet("{walletId}/transactions")]
public async Task<IActionResult> WalletTransactions(
@@ -591,14 +726,30 @@ namespace BTCPayServer.Controllers
return NotFound();
var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
var wallet = walletProvider.GetWallet(network);
-
- // We can't filter at the database level if we need to apply label filter
- var preFiltering = string.IsNullOrEmpty(labelFilter);
- const int maxVisibleLabels = 20;
+ var effectiveTimezoneOffset = model.TimezoneOffset ?? 0;
+ var filter = BuildWalletTransactionsFilter(model.SearchTerm, model.SearchText, labelFilter, effectiveTimezoneOffset);
+
+ var filterAtSource = !filter.HasFilters;
+ var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter);
+ model.Search = filter.Search;
+ model.SearchText = filter.SearchText;
+ model.SearchInputText = filter.SearchInputText;
+ model.SearchTerm = filter.SearchTerm;
+ model.TimezoneOffset = effectiveTimezoneOffset;
+ model.HasFilters = filter.HasFilters || !string.IsNullOrWhiteSpace(labelFilter);
+ model.PaginationQuery = new Dictionary<string, object>
+ {
+ { "searchTerm", filter.SearchTerm },
+ { "searchText", filter.SearchText },
+ { "timezoneOffset", effectiveTimezoneOffset }
+ };
+ if (!string.IsNullOrEmpty(labelFilter))
+ model.PaginationQuery.Add("labelFilter", labelFilter);
model.PendingTransactions = await pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
+ const int maxVisibleLabels = 20;
var labelsWithUsage = await WalletRepository.GetWalletLabelsByLinkedTypeWithUsage(walletId, WalletObjectData.Types.Tx, includeUnusedLabels: true);
model.Labels.AddRange(labelsWithUsage
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color), c.UsageCount)));
@@ -614,13 +765,24 @@ namespace BTCPayServer.Controllers
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
if (loadTransactions)
{
- transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? model.Skip : null, preFiltering ? model.Count : null, cancellationToken: cancellationToken);
+ transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, filterAtSource ? model.Skip : null, filterAtSource ? model.Count : null, cancellationToken: cancellationToken);
+ if (!filterAtSource)
+ {
+ transactions = transactions
+ .Where(tx => MatchesWalletTransactionBasicFilter(tx, filter, network))
+ .ToList();
+
+ model.Total = transactions.Count;
+
+ if (!requiresMetadataFiltering)
+ {
+ transactions = transactions.Skip(model.Skip).Take(model.Count).ToList();
+ }
+ }
+
walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
}
- if (labelFilter != null)
- {
- model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
- }
+
if (transactions == null || walletTransactionsInfo is null)
{
model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>();
@@ -631,30 +793,41 @@ namespace BTCPayServer.Controllers
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode);
foreach (var tx in transactions)
{
- var vm = new ListTransactionsViewModel.TransactionViewModel();
- vm.Id = tx.TransactionId.ToString();
- vm.Link = transactionLinkProviders.GetTransactionLink(pmi, vm.Id);
- vm.Timestamp = tx.SeenAt;
- vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
- vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
- vm.IsConfirmed = tx.Confirmations != 0;
- vm.HistoryLine = tx;
- // If support isn't possible, we want the user to be able to click so he can see why it doesn't work
- vm.CanBumpFee =
- tx.Confirmations == 0 &&
- (bumpable.Support is not BumpableSupport.Ok || (bumpable.TryGetValue(tx.TransactionId, out var i) ? i.RBF || i.CPFP : false));
- if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
+ WalletTransactionInfo? transactionInfo = null;
+ var labelValues = Array.Empty<string>();
+ TransactionTagModel[] tags = [];
+ if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var info))
{
- var labels = labelService.CreateTransactionTagModels(transactionInfo, Request);
- vm.Tags.AddRange(labels);
+ transactionInfo = info;
+ tags = labelService.CreateTransactionTagModels(info, Request).ToArray();
+ labelValues = info.LabelColors.Keys.ToArray();
+ }
+
+ if (requiresMetadataFiltering && !MatchesWalletTransactionFilter(tx, transactionInfo, labelValues, tags, filter, network))
+ continue;
+
+ var vm = new ListTransactionsViewModel.TransactionViewModel
+ {
+ Id = tx.TransactionId.ToString(),
+ Link = transactionLinkProviders.GetTransactionLink(pmi, tx.TransactionId.ToString()),
+ Timestamp = tx.SeenAt,
+ Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0,
+ Balance = tx.BalanceChange.ShowMoney(wallet.Network),
+ IsConfirmed = tx.Confirmations != 0,
+ HistoryLine = tx,
+ CanBumpFee = tx.Confirmations == 0 &&
+ (bumpable.Support is not BumpableSupport.Ok || (bumpable.TryGetValue(tx.TransactionId, out var i) ? i.RBF || i.CPFP : false))
+ };
+
+ if (transactionInfo is not null)
+ {
+ vm.Tags.AddRange(tags);
vm.Comment = transactionInfo.Comment;
vm.InvoiceId = transactionInfo.Attachments.FirstOrDefault(a => a.Type == WalletObjectData.Types.Invoice)?.Id;
vm.WalletRateBook = transactionInfo.Rates;
}
- if (labelFilter == null ||
- vm.Tags.Any(l => l.Text.Equals(labelFilter, StringComparison.OrdinalIgnoreCase)))
- model.Transactions.Add(vm);
+ model.Transactions.Add(vm);
}
var trackedCurrencies = GetCurrentStore().GetStoreBlob().GetTrackedRates();
@@ -675,14 +848,13 @@ namespace BTCPayServer.Controllers
foreach (var trackedCurrency in trackedCurrencies)
{
var exists = book.TryGetRate(new CurrencyPair(network.CryptoCode, trackedCurrency), out var rate);
- vm.Rates.Add(exists ? displayFormatter.Currency(rate, trackedCurrency) : null);
+ vm.Rates.Add(exists ? displayFormatter.Currency(rate, trackedCurrency) : null);
}
}
- model.Total = preFiltering ? null : model.Transactions.Count;
- // if we couldn't filter at the db level, we need to apply skip and count
- if (!preFiltering)
+ if (requiresMetadataFiltering)
{
+ model.Total = model.Transactions.Count;
model.Transactions = model.Transactions.Skip(model.Skip).Take(model.Count).ToList();
}
}
@@ -1912,7 +2084,12 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/export")]
public async Task<IActionResult> Export(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
- string format, string? labelFilter = null, CancellationToken cancellationToken = default)
+ string format,
+ string? labelFilter = null,
+ string? searchTerm = null,
+ string? searchText = null,
+ int? timezoneOffset = null,
+ CancellationToken cancellationToken = default)
{
var paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
@@ -1920,9 +2097,33 @@ namespace BTCPayServer.Controllers
var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
var wallet = walletProvider.GetWallet(network);
- var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
+ var effectiveTimezoneOffset = timezoneOffset ?? 0;
+ var filter = BuildWalletTransactionsFilter(searchTerm, searchText, labelFilter, effectiveTimezoneOffset);
+ var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter);
+
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
- var walletTransactionsInfo = await walletTransactionsInfoAsync;
+ if (filter.HasFilters || !string.IsNullOrWhiteSpace(labelFilter))
+ {
+ input = input
+ .Where(tx => MatchesWalletTransactionBasicFilter(tx, filter, network))
+ .ToList();
+ }
+
+ var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, input.Select(tx => tx.TransactionId.ToString()).ToArray());
+
+ if (requiresMetadataFiltering)
+ {
+ input = input
+ .Where(tx =>
+ {
+ walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var info);
+ var tags = info is null ? [] : labelService.CreateTransactionTagModels(info, Request).ToArray();
+ var labels = info?.LabelColors.Keys.ToArray() ?? Array.Empty<string>();
+ return MatchesWalletTransactionFilter(tx, info, labels, tags, filter, network);
+ })
+ .ToList();
+ }
+
var export = new TransactionsExport(wallet, walletTransactionsInfo);
var res = export.Process(input, format);
var fileType = format switch
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
index df8ebec..4eaf303 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
@@ -1,9 +1,11 @@
+@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client
@using BTCPayServer.Components
@using BTCPayServer.Components.WalletNav
@using BTCPayServer.TagHelpers
@using System
@using Microsoft.AspNetCore.Html
+@using Microsoft.AspNetCore.Mvc.TagHelpers
@model ListTransactionsViewModel
@{
@@ -13,6 +15,16 @@
var labelFilter = Context.Request.Query["labelFilter"].ToString();
var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode);
storeId = wallet.StoreId;
+ var hasDateFilter = Model.Search?.ContainsFilter("startdate") is true || Model.Search?.ContainsFilter("enddate") is true;
+ var hasNoLabelFilter = Model.Search?.ContainsFilter("nolabel") is true && Model.Search.GetFilterBool("nolabel") is true;
+ var searchLabelFilters = Model.Search?.GetFilterArray("label") ?? Array.Empty<string>();
+ var hasLegacyLabelFilter = !string.IsNullOrEmpty(labelFilter) && !searchLabelFilters.Any(value => value.Equals(labelFilter, StringComparison.OrdinalIgnoreCase));
+ var labelFilterCount = searchLabelFilters.Length + (hasLegacyLabelFilter ? 1 : 0) + (hasNoLabelFilter ? 1 : 0);
+ var directionFilters = Model.Search?.GetFilterArray("direction") ?? Array.Empty<string>();
+ var hasIncomingFilter = directionFilters.Any(value => value.Equals("in", StringComparison.OrdinalIgnoreCase));
+ var hasOutgoingFilter = directionFilters.Any(value => value.Equals("out", StringComparison.OrdinalIgnoreCase));
+ var hasIncomingOnlyFilter = hasIncomingFilter && !hasOutgoingFilter;
+ var hasOutgoingOnlyFilter = hasOutgoingFilter && !hasIncomingFilter;
const int maxVisibleLabels = 20;
var sortedLabels = Model.Labels.OrderBy(l => l.Text, StringComparer.OrdinalIgnoreCase).ToList();
var popularLabels = Model.PopularLabels.OrderBy(l => l.Text, StringComparer.OrdinalIgnoreCase).ToList();
@@ -35,14 +47,69 @@
}
}
+ #SearchText {
+ flex-shrink: 0;
+ min-width: 200px;
+ max-width: 320px;
+ }
+
+ #TransactionsToolbar {
+ align-items: flex-start;
+ }
+
+ #WalletTransactionsSearch {
+ flex: 1 1 52rem;
+ min-width: 0;
+ }
+
+ #Dropdowns {
+ align-items: center;
+ justify-content: flex-start;
+ min-width: 0;
+ }
+
+ #Export {
+ align-items: center;
+ }
+
+ #Export > .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ text-align: center;
+ padding-left: var(--bs-btn-padding-x);
+ padding-right: var(--bs-btn-padding-x);
+ }
+
+ #Export > .btn .icon {
+ margin-left: 0;
+ }
+
+ #view-report {
+ min-width: 7rem;
+ }
+
+ #ExportDropdownToggle::after {
+ margin-left: 0.5rem;
+ }
+
+ @@media (max-width: 568px) {
+ #SearchText {
+ width: 100%;
+ }
+ }
+
#LabelDropdownMenu {
max-height: 480px;
overflow-y: auto;
scrollbar-width: thin;
}
+
#LabelDropdownMenu #LabelSearchContainer .input-group:focus-within {
border-color: var(--btcpay-form-border-focus) !important;
}
+
#LabelDropdownMenu #LabelSearchContainer {
position: sticky;
top: 0;
@@ -50,40 +117,48 @@
background: var(--btcpay-dropdown-bg);
border-bottom: var(--bs-border-width, 1px) solid var(--bs-border-color, rgba(0,0,0,.175));
}
- #LabelDropdownMenu .label-filter-divider {
- display: none;
- }
- #LabelDropdownMenu .label-filter-clear {
- position: sticky;
- bottom: 0;
- background: var(--btcpay-dropdown-bg);
- border-top: var(--bs-border-width, 1px) solid var(--bs-border-color, rgba(0,0,0,.175));
- }
- #LabelDropdownMenu .label-filter-clear .dropdown-item {
- padding-top: 0.75rem;
- padding-bottom: 0.75rem;
- }
- #LabelDropdownMenu .label-filter-item .label-filter-link {
+
+ #LabelDropdownMenu .label-filter-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
- #LabelDropdownMenu .label-filter-item .label-filter-count {
+
+ #LabelDropdownMenu .label-filter-count {
margin-left: auto;
text-align: right;
opacity: 0.75;
}
- /* pull actions area, so that it is besides the search form */
- @@media (min-width: 1200px) {
- #Filter + #Dropdowns {
- margin-top: -4rem;
- float: right;
+ .dropdown > .btn {
+ min-width: 7rem;
+ padding-left: 1rem;
+ text-align: left;
+ }
+
+ @@media (max-width: 1399.98px) {
+ #WalletTransactionsSearch,
+ #Dropdowns {
+ width: 100%;
+ flex-basis: 100%;
}
- #Filter + #Dropdowns #Actions {
- order: 1;
+ #SearchText {
+ width: 100%;
+ max-width: none;
+ flex-basis: 100%;
+ }
+ }
+
+ @@media (min-width: 1400px) {
+ #TransactionsToolbar {
+ align-items: center;
+ }
+
+ #Dropdowns {
+ margin-left: auto;
+ justify-content: flex-end;
}
}
@@ -103,21 +178,37 @@
const $list = document.getElementById('WalletTransactionsList');
const $dropdowns = document.getElementById('Dropdowns');
const $indicator = document.getElementById('LoadingIndicator');
+ const $walletTransactionsSearch = document.getElementById('WalletTransactionsSearch');
+ const $searchTerm = $walletTransactionsSearch?.querySelector('input[name="SearchTerm"]');
+ const $searchText = document.getElementById('SearchText');
delegate('click', '#GoToTop', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
+ function setSearchFilters(search, key, values = []) {
+ const filters = (search || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(value => value && !value.toLowerCase().startsWith(`${key.toLowerCase()}:`));
+
+ values
+ .filter(value => value)
+ .forEach(value => filters.push(`${key}:${value}`));
+
+ return filters.join(',');
+ }
+
const $labelSearch = document.getElementById('LabelSearch');
if ($labelSearch) {
- let allLabels = @Safe.Json(sortedLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
+ const allLabels = @Safe.Json(sortedLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
const initialLabels = @Safe.Json(initialLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
- const activeLabel = @Safe.Json(labelFilter);
+ const activeLabels = @Safe.Json(searchLabelFilters.Concat(string.IsNullOrEmpty(labelFilter) ? Array.Empty<string>() : new[] { labelFilter }).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
const $menu = document.getElementById('LabelDropdownMenu');
+ const $anchor = document.getElementById('LabelFilterResultsAnchor');
function renderLabelItems(labels) {
$menu.querySelectorAll('.label-filter-item').forEach(el => el.remove());
- const divider = $menu.querySelector('.label-filter-divider');
const fragment = document.createDocumentFragment();
const $template = document.getElementById('label-filter-item-template');
@@ -127,7 +218,7 @@
const labelText = a.querySelector('.label-filter-text');
const labelCount = a.querySelector('.label-filter-count');
a.href = a.getAttribute('href').replace('LABEL_PLACEHOLDER', encodeURIComponent(label.text));
- if (label.text === activeLabel) a.classList.add('active');
+ if (activeLabels.some(value => value.toLowerCase() === label.text.toLowerCase())) a.classList.add('active');
a.style.setProperty('--btcpay-dropdown-link-active-bg', label.color);
a.style.setProperty('--btcpay-dropdown-link-active-color', label.textColor);
labelText.textContent = label.text;
@@ -141,14 +232,9 @@
fragment.appendChild(labelFilterItem);
});
- if (divider) {
- $menu.insertBefore(fragment, divider);
- } else {
- $menu.appendChild(fragment);
- }
+ $anchor.after(fragment);
}
- // Keep the dropdown open while typing in the search box
$labelSearch.addEventListener('click', e => e.stopPropagation());
$labelSearch.addEventListener('input', () => {
const query = $labelSearch.value.toLowerCase().trim();
@@ -160,10 +246,33 @@
document.getElementById('GoToTop').classList.remove('d-none');
}
+ document.querySelectorAll('[data-direction-filter]').forEach($item => {
+ $item.addEventListener('click', () => {
+ if (!$walletTransactionsSearch || !$searchTerm) return;
+
+ const direction = $item.dataset.directionFilter;
+ $searchTerm.value = setSearchFilters($searchTerm.value, 'direction', direction ? [direction] : []);
+ $walletTransactionsSearch.submit();
+ });
+ });
+
+ if ($searchText && $walletTransactionsSearch) {
+ $searchText.addEventListener('keydown', event => {
+ if (event.key !== 'Enter') return;
+
+ event.preventDefault();
+ if (typeof $walletTransactionsSearch.requestSubmit === 'function') {
+ $walletTransactionsSearch.requestSubmit();
+ } else {
+ $walletTransactionsSearch.submit();
+ }
+ });
+ }
+
const count = @Safe.Json(Model.Count);
const skipInitial = @Safe.Json(Model.Skip);
- const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
- // The next time we load transactions, skip will become 0
+ const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, searchTerm = Model.SearchTerm, searchText = Model.SearchText, timezoneOffset = Model.TimezoneOffset, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
+ // The next time we load transactions, skip will become 0
let skip = @Safe.Json(Model.Skip) - count;
async function loadMoreTransactions() {
@@ -203,7 +312,72 @@
initLabelManagers();
}
- const observer = new IntersectionObserver(async entries => {
+ $('#btnCustomRangeDate').on('click', function () {
+ const dtpStartDate = $("#dtpStartDate").val();
+ const dtpEndDate = $("#dtpEndDate").val();
+
+ if (!dtpStartDate && !dtpEndDate) {
+ $("#dtpStartDate").next().trigger("focus");
+ return;
+ }
+
+ const dateFilters = [];
+ if (dtpStartDate) {
+ dateFilters.push(`startdate:${dtpStartDate}`);
+ }
+ if (dtpEndDate) {
+ dateFilters.push(`enddate:${dtpEndDate}`);
+ }
+
+ const currentSearchTerm = $('#WalletTransactionsSearch input[name="SearchTerm"]').val() || '';
+ const cleanedSearchTerm = currentSearchTerm
+ .split(',')
+ .map(value => value.trim())
+ .filter(value => value && !value.toLowerCase().startsWith('startdate:') && !value.toLowerCase().startsWith('enddate:'))
+ .join(',');
+ const combinedSearchTerm = [cleanedSearchTerm, dateFilters.join(',')].filter(Boolean).join(',');
+
+ const baseUrl = "@Url.Action("WalletTransactions", new { walletId, labelFilter })";
+ const url = new URL(baseUrl, window.location.origin);
+ url.searchParams.set("Count", $("#Count").val());
+ url.searchParams.set("timezoneOffset", $("#TimezoneOffset").val());
+
+ if (combinedSearchTerm) {
+ url.searchParams.set("SearchTerm", combinedSearchTerm);
+ } else {
+ url.searchParams.delete("SearchTerm");
+ }
+
+ const searchText = $('#SearchText').val() || '';
+ if (searchText) {
+ url.searchParams.set("SearchText", searchText);
+ } else {
+ url.searchParams.delete("SearchText");
+ }
+
+ window.location.href = url.toString();
+ });
+
+ const clearBtn = document.getElementById('clearAllFiltersBtn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', function () {
+ const form = clearBtn.closest('form');
+ if (!form) return;
+
+ const searchTextInput = form.querySelector('[name="SearchText"]');
+ if (searchTextInput) searchTextInput.value = '';
+
+ const searchTermInput = form.querySelector('[name="SearchTerm"]');
+ if (searchTermInput) searchTermInput.value = '';
+
+ const labelFilterInput = form.querySelector('[name="labelFilter"]');
+ if (labelFilterInput) labelFilterInput.value = '';
+
+ form.submit();
+ });
+ }
+
+ const observer = new IntersectionObserver(async entries => {
const { isIntersecting } = entries[0];
if (isIntersecting) {
await loadMoreTransactions();
@@ -220,85 +394,254 @@
</div>
<partial name="_StatusMessage" />
-<div class="d-flex flex-wrap align-items-center justify-content-between gap-3" id="Dropdowns">
- @if (Model.Labels.Any())
+@functions
+{
+ private bool HasArrayFilter(string type, string key = null) =>
+ Model.Search?.ContainsFilter(type) is true && (key is null || Model.Search.GetFilterArray(type).Contains(key));
+
+ private bool HasBooleanFilter(string key) =>
+ Model.Search?.ContainsFilter(key) is true && Model.Search.GetFilterBool(key) is true;
+
+ private bool HasCustomDateFilter() =>
+ Model.Search?.ContainsFilter("enddate") is true ||
+ (Model.Search?.ContainsFilter("startdate") is true &&
+ !HasArrayFilter("startdate", "-1d") &&
+ !HasArrayFilter("startdate", "-3d") &&
+ !HasArrayFilter("startdate", "-7d"));
+
+ private string SetSearchFilter(string search, string key, params string[] values)
{
- <div class="btn-group" id="Filter">
- <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
- @if (string.IsNullOrEmpty(labelFilter))
+ var filters = (search ?? string.Empty)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(value => value.Trim())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Where(value => !value.StartsWith($"{key}:", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)))
+ {
+ filters.Add($"{key}:{value}");
+ }
+
+ return filters.Count > 0 ? string.Join(',', filters) : string.Empty;
+ }
+
+ private string SetSearchFilter(string key, params string[] values) =>
+ SetSearchFilter(Model.SearchTerm, key, values);
+}
+
+@* Custom Range Modal *@
+<div class="modal fade" id="customRangeModal" tabindex="-1" role="dialog" aria-labelledby="customRangeModalTitle" aria-hidden="true" data-bs-backdrop="static">
+ <div class="modal-dialog modal-dialog-centered" role="document" style="max-width: 550px;">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="customRangeModalTitle" text-translate="true">Filter transactions by Custom Range</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label for="dtpStartDate" class="col-sm-3 col-form-label" text-translate="true">Start Date</label>
+ <div class="col-sm-9">
+ <div class="input-group">
+ <input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
+ placeholder="@StringLocalizer["Start Date"]" />
+ <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="dtpEndDate" class="col-sm-3 col-form-label" text-translate="true">End Date</label>
+ <div class="col-sm-9">
+ <div class="input-group">
+ <input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
+ placeholder="@StringLocalizer["End Date"]" />
+ <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button id="btnCustomRangeDate" type="button" class="btn btn-primary" text-translate="true">Filter</button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div id="TransactionsToolbar" class="d-flex flex-wrap gap-3 mb-4">
+ <form id="WalletTransactionsSearch" class="d-flex flex-wrap align-items-center gap-3 mb-0" asp-action="WalletTransactions" asp-route-walletId="@walletId" method="get">
+ <input id="Count" name="Count" type="hidden" value="@Model.Count" />
+ <input id="TimezoneOffset" name="TimezoneOffset" type="hidden" value="@Model.TimezoneOffset" />
+ <input name="SearchTerm" type="hidden" value="@Model.SearchTerm" />
+ @if (!string.IsNullOrEmpty(labelFilter))
+ {
+ <input name="labelFilter" type="hidden" value="@labelFilter" />
+ }
+ <input id="SearchText" name="SearchText" class="form-control" value="@Model.SearchInputText" placeholder="@StringLocalizer["Search transactions..."]" aria-label="@StringLocalizer["Search transactions"]" />
+ <div class="dropdown">
+ <button id="DirectionOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @if (hasIncomingOnlyFilter)
+ {
+ <span text-translate="true">Incoming</span>
+ }
+ else if (hasOutgoingOnlyFilter)
{
- <span class="text-secondary" text-translate="true">Filter by label</span>
+ <span text-translate="true">Outgoing</span>
}
else
{
- <span class="text-secondary" text-translate="true">Label:</span>
- <span>@labelFilter</span>
+ <span text-translate="true">All Directions</span>
}
</button>
- @{
- var visibleLabels = initialLabels;
- }
- <ul class="dropdown-menu mt-1 py-0" id="LabelDropdownMenu" style="min-width:280px">
- <li class="px-2 pt-2 pb-2@(sortedLabels.Count() > maxVisibleLabels ? "" : " d-none")" id="LabelSearchContainer">
- <div class="input-group border rounded">
- <span class="input-group-text border-0 text-muted px-2" style="background:transparent">
- <vc:icon symbol="actions-search" />
- </span>
- <input type="text" class="form-control border-0 shadow-none ps-0 bg-transparent" id="LabelSearch"
- placeholder="@StringLocalizer["Search labels…"]" autocomplete="off" aria-label="@StringLocalizer["Search labels…"]"/>
- </div>
- </li>
-
- @foreach (var label in visibleLabels)
- {
+ <div class="dropdown-menu" aria-labelledby="DirectionOptionsToggle">
+ <button type="button" class="dropdown-item @((!hasIncomingOnlyFilter && !hasOutgoingOnlyFilter) ? "custom-active" : string.Empty)" data-direction-filter="" text-translate="true">All Directions</button>
+ <button type="button" class="dropdown-item @(hasIncomingOnlyFilter ? "custom-active" : string.Empty)" data-direction-filter="in" text-translate="true">Incoming</button>
+ <button type="button" class="dropdown-item @(hasOutgoingOnlyFilter ? "custom-active" : string.Empty)" data-direction-filter="out" text-translate="true">Outgoing</button>
+ </div>
+ </div>
+ @if (Model.Labels.Any())
+ {
+ <div class="dropdown">
+ <button id="LabelOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @if (labelFilterCount > 1)
+ {
+ <span>@StringLocalizer["{0} Labels", labelFilterCount]</span>
+ }
+ else if (searchLabelFilters.Length == 1)
+ {
+ <span text-translate="true">Label:</span>
+ <span>@searchLabelFilters[0]</span>
+ }
+ else if (!string.IsNullOrEmpty(labelFilter))
+ {
+ <span text-translate="true">Label:</span>
+ <span>@labelFilter</span>
+ }
+ else if (hasNoLabelFilter)
+ {
+ <span text-translate="true">No Label</span>
+ }
+ else
+ {
+ <span text-translate="true">All Labels</span>
+ }
+ </button>
+ @{
+ var visibleLabels = initialLabels;
+ }
+ <ul class="dropdown-menu mt-1 py-0" id="LabelDropdownMenu" aria-labelledby="LabelOptionsToggle" style="min-width:280px">
+ <li class="px-2 pt-2 pb-2@(sortedLabels.Count > maxVisibleLabels ? string.Empty : " d-none")" id="LabelSearchContainer">
+ <div class="input-group border rounded">
+ <span class="input-group-text border-0 text-muted px-2" style="background:transparent">
+ <vc:icon symbol="actions-search" />
+ </span>
+ <input type="text" class="form-control border-0 shadow-none ps-0 bg-transparent" id="LabelSearch"
+ placeholder="@StringLocalizer["Search labels…"]" autocomplete="off" aria-label="@StringLocalizer["Search labels…"]" />
+ </div>
+ </li>
+ <li>
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("label"), "nolabel")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(labelFilterCount == 0 ? "custom-active" : string.Empty)" text-translate="true">All Labels</a>
+ </li>
+ <li>
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("label"), "nolabel", "true")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(hasNoLabelFilter ? "custom-active" : string.Empty)" text-translate="true">No Label</a>
+ </li>
+ <li><hr class="dropdown-divider"></li>
+ <li id="LabelFilterResultsAnchor" class="d-none"></li>
+ @foreach (var label in visibleLabels)
+ {
+ <li class="label-filter-item">
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("nolabel"), "label", label.Text)" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item transaction-label-text label-filter-link@((searchLabelFilters.Any(value => value.Equals(label.Text, StringComparison.OrdinalIgnoreCase)) || labelFilter == label.Text) ? " active" : string.Empty)" style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
+ <span class="label-filter-text">@label.Text</span>
+ <small class="label-filter-count@(label.UsageCount > 0 ? string.Empty : " d-none")">@label.UsageCount</small>
+ </a>
+ </li>
+ }
+ </ul>
+ <template id="label-filter-item-template">
<li class="label-filter-item">
- <a asp-route-labelFilter="@label.Text" class="dropdown-item transaction-label-text label-filter-link@(labelFilter == label.Text ? " active" : string.Empty)" style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
- <span class="label-filter-text">@label.Text</span>
- <small class="label-filter-count@(label.UsageCount > 0 ? string.Empty : " d-none")">@label.UsageCount</small>
+ <a href="@Url.Action("WalletTransactions", new { walletId, labelFilter = string.Empty, count = Model.Count, searchText = Model.SearchText, searchTerm = SetSearchFilter(SetSearchFilter("nolabel"), "label", "LABEL_PLACEHOLDER"), timezoneOffset = Model.TimezoneOffset })" class="dropdown-item transaction-label-text label-filter-link">
+ <span class="label-filter-text"></span>
+ <small class="label-filter-count d-none"></small>
</a>
</li>
+ </template>
+ </div>
+ }
+ <div class="dropdown">
+ <button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @if (hasDateFilter)
+ {
+ if (HasArrayFilter("startdate", "-1d"))
+ {
+ <span text-translate="true">24 Hours</span>
+ }
+ else if (HasArrayFilter("startdate", "-3d"))
+ {
+ <span text-translate="true">3 Days</span>
+ }
+ else if (HasArrayFilter("startdate", "-7d"))
+ {
+ <span text-translate="true">7 Days</span>
+ }
+ else
+ {
+ <span text-translate="true">Custom</span>
+ }
}
- @if (!string.IsNullOrEmpty(labelFilter))
+ else
{
- <li class="label-filter-divider"><hr class="dropdown-divider"></li>
- <li class="label-filter-clear"><a class="dropdown-item" asp-route-labelFilter="" text-translate="true">Clear filter</a></li>
+ <span text-translate="true">All Time</span>
}
- </ul>
- <template id="label-filter-item-template">
- <li class="label-filter-item">
- <a asp-route-labelFilter="LABEL_PLACEHOLDER" class="dropdown-item transaction-label-text label-filter-link">
- <span class="label-filter-text"></span>
- <small class="label-filter-count d-none"></small>
- </a>
- </li>
- </template>
+ </button>
+ <div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("startdate"), "enddate")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @((!HasArrayFilter("startdate") && !HasCustomDateFilter()) ? "custom-active" : string.Empty)" text-translate="true">All Time</a>
+ <hr class="dropdown-divider" />
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-1d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")" text-translate="true">Last 24 hours</a>
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-3d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")" text-translate="true">Last 3 days</a>
+ <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-7d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")" text-translate="true">Last 7 days</a>
+ <button type="button" class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")" data-bs-toggle="modal" data-bs-target="#customRangeModal" text-translate="true">Custom Range</button>
+ </div>
</div>
- }
-
- <div class="dropdown d-inline-flex align-items-center gap-3 ms-auto" id="Export" permission="@WalletPolicies.CanViewWallet">
- <a
- id="view-report"
- permission="@Policies.CanViewReports"
- asp-area=""
- asp-controller="UIReports"
- asp-action="StoreReports"
- asp-route-storeId="@storeId"
- asp-route-viewName="Wallets"
- class="btn btn-secondary">
- <vc:icon symbol="nav-reporting" />
- <span text-translate="true">Reporting</span>
- </a>
- <button class="btn btn-secondary dropdown-toggle" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
- Export
- </button>
- <div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="csv" asp-route-labelFilter="@labelFilter" class="dropdown-item export-link" target="_blank" id="ExportCSV" text-translate="true">CSV</a>
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="json" asp-route-labelFilter="@labelFilter" class="dropdown-item export-link" target="_blank" id="ExportJSON" text-translate="true">JSON</a>
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="bip329" asp-route-labelFilter="@labelFilter" class="dropdown-item export-link" target="_blank" id="ExportBIP329" text-translate="true">Wallet Labels (BIP-329)</a>
+ @if (Model.HasFilters || !string.IsNullOrEmpty(labelFilter))
+ {
+ <button id="clearAllFiltersBtn" type="button" class="btn btn-secondary" style="min-width: 7rem;" title="@StringLocalizer["Clear all filters"]">
+ <span class="align-middle" text-translate="true">Clear All</span>
+ </button>
+ }
+ </form>
+
+ <div class="d-flex flex-wrap align-items-center gap-3" id="Dropdowns">
+ <div class="dropdown d-flex flex-wrap gap-3" id="Export" permission="@WalletPolicies.CanViewWallet">
+ <a
+ id="view-report"
+ permission="@Policies.CanViewReports"
+ asp-area=""
+ asp-controller="UIReports"
+ asp-action="StoreReports"
+ asp-route-storeId="@storeId"
+ asp-route-viewName="Wallets"
+ class="btn btn-secondary">
+ <vc:icon symbol="nav-reporting" />
+ <span text-translate="true">Reporting</span>
+ </a>
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
+ Export
+ </button>
+ <div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
+ <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="csv" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportCSV" text-translate="true">CSV</a>
+ <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="json" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportJSON" text-translate="true">JSON</a>
+ <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="bip329" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportBIP329" text-translate="true">Wallet Labels (BIP-329)</a>
+ </div>
</div>
</div>
</div>
-<div style="clear:both"></div>
@if (Model.PendingTransactions?.Any() == true)
{
diff --git a/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
index d79ae5c..b9ab502 100644
--- a/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
+++ b/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using BTCPayServer;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Services.Invoices;
@@ -33,6 +34,10 @@ namespace BTCPayServer.Plugins.Wallets.Views.ViewModels
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
public PendingTransaction[] PendingTransactions { get; set; }
- public List<string> Rates { get; set; }
+ public List<string> Rates { get; set; } = new();
+ public string SearchText { get; set; }
+ public string SearchInputText { get; set; }
+ public SearchString Search { get; set; }
+ public bool HasFilters { get; set; }
}
}
diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs
index 8fe43fc..3ce659d 100644
--- a/BTCPayServer/SearchString.cs
+++ b/BTCPayServer/SearchString.cs
@@ -9,7 +9,7 @@ namespace BTCPayServer
{
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
- private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate"];
+ private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate", "label", "nolabel", "direction"];
private readonly string _originalString;
private readonly int _timezoneOffset;
Why this scored 22/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.