feat: improve payment requests listing, filters and reporting
What changed, and why it matters
This commit adds new UI filters, a label system, and a reporting provider for payment requests. It also changes the search query to use a hand-written SQL statement. The change is described as a feature improvement, not a security fix. The SQL query uses Entity Framework's parameterized raw SQL, which generally protects against SQL injection, but the code mixes raw SQL with LINQ and introduces a new label-filtering path. There is no direct evidence in the commit of a vulnerability being fixed or introduced, but the raw SQL and label-filtering logic are worth reviewing carefully because mistakes in those areas can lead to security bugs.
Treat this as a routine feature commit, but perform a focused security review of the new raw SQL query and label-filtering path. Verify that FromSqlRaw parameters are correctly translated by EF Core in the deployed version, that LabelFilter values are validated or parameterized, and that the reporting provider enforces store-scoped authorization. No emergency action is warranted based solely on this diff.
Security signals we found
Raw SQL introduced via FromSqlRaw in PaymentRequestRepository.FindPaymentRequests
User-supplied SearchText, LabelFilter, startdate/enddate values flow into repository queries
New label filtering joins WalletObjectLinks by WalletId/AType/BType/AId
New reporting provider reads payment requests, invoices, and payments for a store
No explicit security fix, advisory, CVE, or researcher attribution in commit metadata
Evidence from the diff
The commit refactors UIPaymentRequestController.GetPaymentRequests to add date range filtering, label filtering, and label display. PaymentRequestRepository.FindPaymentRequests now uses FromSqlRaw with two parameters (StoreId, SearchText) for text search, and adds StartDate/EndDate/LabelFilter query handling. A new PaymentRequestsReportProvider is registered and implemented. WalletRepository gains GetWalletLabelsByLinkedType. The view is updated with new filter controls and a label-manager component. The raw SQL uses positional parameters {0} and {1}, which EF Core parameterizes, so direct SQL injection is unlikely from this diff alone. LabelFilter is matched against WalletObjectLinks.AId and then used in an IN clause, but the values come from user input via the route/query string; the code does not appear to sanitize or validate the label value beyond EF Core’s query translation. No security relevance, CVE, or researcher attribution is stated in the commit or supplied references.
Changed components
BTCPayServer/Controllers/UIPaymentRequestController.csBTCPayServer/Services/PaymentRequests/PaymentRequestRepository.csBTCPayServer/Services/Reporting/PaymentRequestsReportProvider.csBTCPayServer/Services/WalletRepository.csBTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtmlInspect captured patch +640 / −57
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index 8ee138f..9e0de99 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -14,6 +14,7 @@ using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
+using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -46,6 +47,7 @@ namespace BTCPayServer.Controllers
private readonly StoreRepository _storeRepository;
private readonly UriResolver _uriResolver;
private readonly BTCPayNetworkProvider _networkProvider;
+ private readonly WalletRepository _walletRepository;
private FormComponentProviders FormProviders { get; }
public FormDataService FormDataService { get; }
@@ -66,7 +68,8 @@ namespace BTCPayServer.Controllers
FormDataService formDataService,
IStringLocalizer stringLocalizer,
ApplicationDbContextFactory dbContextFactory,
- BTCPayNetworkProvider networkProvider)
+ BTCPayNetworkProvider networkProvider,
+ WalletRepository walletRepository)
{
_InvoiceController = invoiceController;
_handlers = handlers;
@@ -83,6 +86,7 @@ namespace BTCPayServer.Controllers
FormDataService = formDataService;
_networkProvider = networkProvider;
StringLocalizer = stringLocalizer;
+ _walletRepository = walletRepository;
}
[HttpGet("/stores/{storeId}/payment-requests")]
@@ -92,29 +96,65 @@ namespace BTCPayServer.Controllers
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
var store = GetCurrentStore();
- var fs = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0);
+ 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;
+ var startDate = fs.GetFilterDate("startdate", timezoneOffset);
+ var endDate = fs.GetFilterDate("enddate", timezoneOffset);
+
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
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(),
IncludeArchived = fs.GetFilterBool("includearchived") ?? false,
- SearchText = model.SearchText
+ SearchText = model.SearchText,
+ StartDate = startDate,
+ EndDate = endDate,
+ LabelFilter = model.LabelFilter
});
model.Search = fs;
- model.SearchText = fs.TextSearch;
+ model.SearchText = textSearch;
- model.Items = result.Select(data =>
+ var items = result.Select(data => new ViewPaymentRequestViewModel(data)
{
- return new ViewPaymentRequestViewModel(data)
- {
- AmountFormatted = _displayFormatter.Currency(data.Amount, data.Currency)
- };
+ AmountFormatted = _displayFormatter.Currency(data.Amount, data.Currency)
}).ToList();
+ foreach (var item in items)
+ {
+ var objectId = new WalletObjectId(walletId, WalletObjectData.Types.PaymentRequest, item.Id);
+
+ var labelTuples = await _walletRepository.GetWalletLabels(objectId);
+
+ item.Labels = labelTuples.Select(l => new TransactionTagModel
+ {
+ Text = l.Label,
+ Color = l.Color,
+ TextColor = ColorPalette.Default.TextColor(l.Color)
+ }).ToList();
+ }
+
+ var allLabels = await _walletRepository.GetWalletLabelsByLinkedType(walletId, WalletObjectData.Types.PaymentRequest);
+ model.Labels = allLabels
+ .Select(l => new TransactionTagModel
+ {
+ Text = l.Label,
+ Color = l.Color,
+ TextColor = ColorPalette.Default.TextColor(l.Color)
+ })
+ .OrderBy(l => l.Text)
+ .ToList();
+
+ model.Items = items;
return View(model);
}
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index d376811..48fdcce 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -628,7 +628,7 @@ namespace BTCPayServer.Controllers
model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
model.Labels.AddRange(
- (await WalletRepository.GetWalletLabels(walletId))
+ (await WalletRepository.GetWalletLabelsByLinkedType(walletId, WalletObjectData.Types.Tx))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
IList<TransactionHistoryLine>? transactions = null;
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index c5d1cea..a1cb427 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -403,6 +403,7 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<InvoicesReportProvider>();
services.AddReportProvider<RefundsReportProvider>();
+ services.AddReportProvider<PaymentRequestsReportProvider>();
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
index a4ab146..ed58b9b 100644
--- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
+++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
@@ -4,13 +4,12 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
-using BTCPayServer.Payments;
+using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
-using Newtonsoft.Json.Linq;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
namespace BTCPayServer.Models.PaymentRequestViewModels
@@ -19,9 +18,12 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
{
public List<ViewPaymentRequestViewModel> Items { get; set; }
public override int CurrentPageCount => Items.Count;
-
+
public SearchString Search { get; set; }
public string SearchText { get; set; }
+ public string WalletId { get; set; }
+ public string LabelFilter { get; set; }
+ public List<TransactionTagModel> Labels { get; set; } = new();
}
public class UpdatePaymentRequestViewModel
@@ -72,10 +74,10 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
[Display(Name = "Expiration Date")]
public DateTime? ExpiryDate { get; set; }
-
+
[Required]
public string Title { get; set; }
-
+
[Display(Name = "Memo")]
public string Description { get; set; }
@@ -84,7 +86,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
[MailboxAddress]
public string Email { get; set; }
-
+
[Display(Name = "Reference Id")]
public string ReferenceId { get; set; }
@@ -112,6 +114,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
Email = blob.Email;
ReferenceId = data.ReferenceId;
AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts;
+ Created = data.Created;
switch (data.Status)
{
case Client.Models.PaymentRequestStatus.Pending:
@@ -145,10 +148,12 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public string StoreId { get; set; }
public string Currency { get; set; }
public DateTime? ExpiryDate { get; set; }
+ public DateTimeOffset Created { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string StoreName { get; set; }
public string StoreWebsite { get; set; }
+ public List<TransactionTagModel> Labels { get; set; }
#nullable enable
public class InvoiceList : List<PaymentRequestInvoice>
diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
index d31d70e..8d622aa 100644
--- a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
+++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
@@ -2,13 +2,10 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using BTCPayServer.Abstractions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
-using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.PaymentRequests
{
@@ -138,17 +135,57 @@ namespace BTCPayServer.Services.PaymentRequests
public async Task<PaymentRequestData[]> FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default)
{
await using var context = _ContextFactory.CreateContext();
- IQueryable<PaymentRequestData> queryable = context.PaymentRequests.AsQueryable();
-
- if (!string.IsNullOrEmpty(query.StoreId))
- queryable = queryable.Where(data => data.StoreDataId == query.StoreId);
+ IQueryable<PaymentRequestData> queryable;
if (!string.IsNullOrEmpty(query.SearchText))
{
if (string.IsNullOrEmpty(query.StoreId))
throw new InvalidOperationException("PaymentRequestQuery.StoreId should be specified");
+
+ var search = query.SearchText;
// We are repeating the StoreId on purpose here, so Postgres can use the index
- queryable = context.PaymentRequests.Where(p => (p.StoreDataId == query.StoreId && p.ReferenceId == query.SearchText) || p.Id == query.SearchText);
+ queryable = context.PaymentRequests.FromSqlRaw("""
+ SELECT *
+ FROM "PaymentRequests"
+ WHERE
+ "StoreDataId" = {0}
+ AND (
+ "ReferenceId" = {1}
+ OR "Id" = {1}
+ OR jsonb_extract_path_text("Blob2", 'title') = {1}
+ OR CAST("Amount" AS TEXT) = {1}
+ )
+ """, query.StoreId, search);
+ }
+ else
+ {
+ queryable = context.PaymentRequests.AsQueryable();
+
+ if (!string.IsNullOrEmpty(query.StoreId))
+ {
+ queryable = queryable.Where(data => data.StoreDataId == query.StoreId);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(query.LabelFilter))
+ {
+ if (string.IsNullOrEmpty(query.StoreId))
+ throw new InvalidOperationException("PaymentRequestQuery.StoreId 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.Include(data => data.StoreData);
@@ -166,6 +203,12 @@ namespace BTCPayServer.Services.PaymentRequests
queryable = queryable.Where(data =>
data.StoreData.UserStores.Any(u => u.ApplicationUserId == query.UserId));
+ if (query.StartDate.HasValue)
+ queryable = queryable.Where(data => data.Created >= query.StartDate.Value);
+
+ if (query.EndDate.HasValue)
+ queryable = queryable.Where(data => data.Created <= query.EndDate.Value);
+
queryable = queryable.OrderByDescending(u => u.Created);
if (query.Skip.HasValue)
@@ -221,12 +264,16 @@ namespace BTCPayServer.Services.PaymentRequests
public class PaymentRequestQuery
{
public string StoreId { get; set; }
+ public string WalletId { get; set; }
public bool IncludeArchived { get; set; } = true;
- public Client.Models.PaymentRequestStatus[] Status { get; set; }
+ public PaymentRequestStatus[] Status { get; set; }
public string UserId { get; set; }
public int? Skip { get; set; }
public int? Count { get; set; }
public string[] Ids { get; set; }
public string SearchText { get; set; }
+ public DateTimeOffset? StartDate { get; set; }
+ public DateTimeOffset? EndDate { get; set; }
+ public string LabelFilter { get; set; }
}
}
diff --git a/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs b/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs
new file mode 100644
index 0000000..88e0eb5
--- /dev/null
+++ b/BTCPayServer/Services/Reporting/PaymentRequestsReportProvider.cs
@@ -0,0 +1,196 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Payments.Lightning;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.PaymentRequests;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Services.Reporting;
+
+public class PaymentRequestsReportProvider(
+ ApplicationDbContextFactory dbContextFactory,
+ InvoiceRepository invoiceRepository,
+ PaymentMethodHandlerDictionary handlers,
+ WalletRepository walletRepository,
+ BTCPayNetworkProvider networkProvider,
+ DisplayFormatter displayFormatter)
+ : ReportProvider
+{
+ public override string Name => "Requests";
+
+ private ViewDefinition CreateViewDefinition()
+ {
+ return new ViewDefinition
+ {
+ Fields =
+ {
+ new StoreReportResponse.Field("Date", "datetime"),
+ new StoreReportResponse.Field("ReferenceId", "string"),
+ new StoreReportResponse.Field("Title", "string"),
+ new StoreReportResponse.Field("Labels", "string"),
+
+ new StoreReportResponse.Field("InvoiceId", "invoice_id"),
+ new StoreReportResponse.Field("OrderId", "string"),
+
+ new StoreReportResponse.Field("PaymentMethod", "string"),
+ new StoreReportResponse.Field("PaymentMethodId", "string"),
+
+ new StoreReportResponse.Field("PaymentCurrency", "string"),
+ new StoreReportResponse.Field("PaymentAmount", "amount"),
+
+ new StoreReportResponse.Field("InvoiceCurrency", "string"),
+ new StoreReportResponse.Field("InvoiceCurrencyAmount", "amount"),
+ new StoreReportResponse.Field("Rate", "amount")
+ },
+ Charts =
+ {
+ new ChartDefinition
+ {
+ Name = "Revenue by label and Payment Method",
+ Groups = { "Labels", "PaymentMethod"},
+ Aggregates = { "InvoiceCurrencyAmount" },
+ Totals = { "Labels" },
+ HasGrandTotal = true
+ }
+ }
+ };
+ }
+
+ public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
+ {
+ queryContext.ViewDefinition = CreateViewDefinition();
+
+ await using var ctx = dbContextFactory.CreateContext();
+
+ var prs = await ctx.PaymentRequests
+ .Where(p => p.StoreDataId == queryContext.StoreId)
+ .Where(p => p.Created >= queryContext.From && p.Created <= queryContext.To)
+ .OrderBy(p => p.Created)
+ .ToListAsync(cancellation);
+
+ if (!prs.Any())
+ return;
+
+ var network = networkProvider.DefaultNetwork;
+ var walletId = new WalletId(queryContext.StoreId, network.CryptoCode);
+
+ var labelsCache = new Dictionary<string, string>();
+
+ var orderIds = prs
+ .Select(pr => PaymentRequestRepository.GetOrderIdForPaymentRequest(pr.Id))
+ .Distinct()
+ .ToArray();
+
+ var invoices = await invoiceRepository.GetInvoices(new InvoiceQuery
+ {
+ StoreId = [queryContext.StoreId],
+ StartDate = queryContext.From,
+ EndDate = queryContext.To,
+ OrderId = orderIds
+ }, cancellation);
+
+ var invoicesByOrderId = invoices
+ .GroupBy(i => i.Metadata.OrderId)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ foreach (var pr in prs)
+ {
+ var prBlob = pr.GetBlob();
+ var prOrderId = PaymentRequestRepository.GetOrderIdForPaymentRequest(pr.Id);
+
+ if (!labelsCache.TryGetValue(pr.Id, out var labelsString))
+ {
+ var objectId = new WalletObjectId(
+ walletId,
+ WalletObjectData.Types.PaymentRequest,
+ pr.Id
+ );
+
+ var labelTuples = await walletRepository.GetWalletLabels(objectId);
+ labelsString = labelTuples.Any()
+ ? string.Join(", ", labelTuples.Select(l => l.Label))
+ : "None";
+
+ labelsCache[pr.Id] = labelsString;
+ }
+
+ if (!invoicesByOrderId.TryGetValue(prOrderId, out var prInvoices) || prInvoices.Count == 0)
+ {
+ var row = queryContext.CreateData();
+ row.Add(pr.Created); // Date
+ row.Add(pr.ReferenceId); // PaymentRequestId
+ row.Add(prBlob.Title); // Title
+ row.Add(labelsString); // Labels
+
+ row.Add(null); // InvoiceId
+ row.Add(prOrderId); // OrderId
+
+ row.Add("No Payment"); // PaymentMethod
+ row.Add("None"); // PaymentMethodId
+
+ row.Add("None"); // PaymentCurrency
+ row.Add(displayFormatter.ToFormattedAmount(0m, pr.Currency));
+
+ row.Add(pr.Currency); // InvoiceCurrency
+ row.Add(displayFormatter.ToFormattedAmount(0m, pr.Currency)); // InvoiceCurrencyAmount
+ row.Add(displayFormatter.ToFormattedAmount(0m, pr.Currency)); // Rate
+
+ queryContext.Data.Add(row);
+ continue;
+ }
+
+ foreach (var invoice in prInvoices)
+ {
+ foreach (var payment in invoice.GetPayments(true))
+ {
+ var row = queryContext.CreateData();
+
+ row.Add(payment.ReceivedTime);
+ row.Add(pr.ReferenceId); // PaymentRequestId
+ row.Add(prBlob.Title); // Title
+ row.Add(labelsString); // Labels
+
+ row.Add(invoice.Id); // InvoiceId
+ row.Add(invoice.Metadata.OrderId); // OrderId
+
+ var paymentMethodId = payment.PaymentMethodId;
+ handlers.TryGetValue(paymentMethodId, out var handler);
+
+ string paymentMethodCategory;
+ if (handler is ILightningPaymentHandler)
+ paymentMethodCategory = "Lightning";
+ else if (handler is BitcoinLikePaymentHandler)
+ paymentMethodCategory = "On-Chain";
+ else
+ paymentMethodCategory = paymentMethodId.ToString(); // plugins, stablecoins, etc.
+
+ row.Add(paymentMethodCategory); // PaymentMethod
+ row.Add(paymentMethodId.ToString()); // PaymentMethodId
+
+ row.Add(payment.Currency); // PaymentCurrency
+ row.Add(displayFormatter.ToFormattedAmount(payment.Value, payment.Currency)); // PaymentAmount
+
+ row.Add(invoice.Currency); // InvoiceCurrency
+
+ if (invoice.TryGetRate(payment.Currency, out var rate))
+ {
+ row.Add(displayFormatter.ToFormattedAmount(rate * payment.Value, invoice.Currency)); // InvoiceCurrencyAmount
+ row.Add(displayFormatter.ToFormattedAmount(rate, invoice.Currency)); // Rate
+ }
+ else
+ {
+ row.Add(displayFormatter.ToFormattedAmount(0m, invoice.Currency));
+ row.Add(displayFormatter.ToFormattedAmount(0m, invoice.Currency));
+ }
+
+ queryContext.Data.Add(row);
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs
index b92d0e0..fc94441 100644
--- a/BTCPayServer/Services/WalletRepository.cs
+++ b/BTCPayServer/Services/WalletRepository.cs
@@ -305,6 +305,25 @@ namespace BTCPayServer.Services
.Select(FormatToLabel).ToArray();
}
+ public async Task<(string Label, string Color)[]> GetWalletLabelsByLinkedType(WalletId walletId, string linkedType)
+ {
+ await using var ctx = _ContextFactory.CreateContext();
+ var walletIdString = walletId.ToString();
+
+ var query =
+ from link in ctx.WalletObjectLinks.AsNoTracking()
+ join labelObj in ctx.WalletObjects.AsNoTracking()
+ on new { link.WalletId, Type = link.AType, Id = link.AId }
+ equals new { labelObj.WalletId, labelObj.Type, labelObj.Id }
+ where link.WalletId == walletIdString
+ && link.AType == WalletObjectData.Types.Label
+ && link.BType == linkedType
+ select labelObj;
+
+ var labelObjects = await query.Distinct().ToArrayAsync();
+ return labelObjects.Select(FormatToLabel).ToArray();
+ }
+
public async Task<List<ReservedAddress>> GetReservedAddressesWithDetails(WalletId walletId)
{
await using var ctx = _ContextFactory.CreateContext();
diff --git a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
index 8a25070..02d527b 100644
--- a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
@@ -1,10 +1,7 @@
@using BTCPayServer.Services.PaymentRequests
@using Microsoft.AspNetCore.Html
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@using BTCPayServer.Components
@using BTCPayServer.Client
@using BTCPayServer.Services
-@using BTCPayServer.TagHelpers
@inject CallbackGenerator CallbackGenerator
@model BTCPayServer.Models.PaymentRequestViewModels.ListPaymentRequestsViewModel
@{
@@ -12,6 +9,21 @@
ViewData.SetLayoutModel(new("PaymentRequests", StringLocalizer["Payment Requests"]));
var storeId = Context.GetStoreData().Id;
var statusFilterCount = CountArrayFilter("status") + (HasBooleanFilter("includearchived") ? 1 : 0);
+ var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate");
+ var hasTextSearch = !string.IsNullOrEmpty(Model.SearchText) || !string.IsNullOrEmpty(Model.Search?.TextSearch);
+ var hasAnyFilter =
+ statusFilterCount > 0 ||
+ hasDateFilter ||
+ hasTextSearch ||
+ !string.IsNullOrEmpty(Model.SearchTerm) ||
+ !string.IsNullOrEmpty(Model.LabelFilter);
+
+ var wallet = WalletId.Parse(Model.WalletId);
+}
+
+@section PageHeadContent {
+ <script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
+ <link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
}
@functions
@@ -24,6 +36,9 @@
private bool HasBooleanFilter(string key) =>
Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
+
+ private bool HasCustomDateFilter() =>
+ Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
}
<div class="sticky-header">
@@ -34,9 +49,26 @@
</a>
</h2>
<a id="page-primary" asp-action="EditPaymentRequest" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" permission="@Policies.CanModifyPaymentRequests"
- text-translate="true">
+ >
Create Request
</a>
+ <div>
+ <a
+ id="view-report"
+ permission="@Policies.CanViewReports"
+ asp-controller="UIReports"
+ asp-action="StoreReports"
+ asp-route-storeId="@storeId"
+ asp-route-viewName="Requests"
+ class="btn btn-secondary">
+ <vc:icon symbol="nav-reporting" />
+ <span text-translate="true">Reporting</span>
+ </a>
+ <a id="page-primary" asp-action="EditPaymentRequest" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button"
+ permission="@Policies.CanModifyPaymentRequests" text-translate="true">
+ Create Request
+ </a>
+ </div>
</div>
<div id="descriptor" class="collapse">
@@ -54,16 +86,65 @@
<partial name="_StatusMessage" />
-<form asp-action="GetPaymentRequests" method="get" class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8">
+@* 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 payment requests 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">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 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>
+
+<form asp-action="GetPaymentRequests"
+ asp-route-storeId="@storeId"
+ method="get"
+ class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8">
<input type="hidden" asp-for="Count" />
<input type="hidden" asp-for="TimezoneOffset" />
<input asp-for="SearchTerm" type="hidden" value="@Model.Search.WithoutSearchText()"/>
- <input asp-for="SearchText" class="form-control" placeholder="@StringLocalizer["Search by Id..."]" />
+ <input asp-for="SearchText" class="form-control" placeholder="@StringLocalizer["Search by Id, Title or Amount..."]" />
+
<div class="dropdown">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
{
- <span>@statusFilterCount Status</span>
+ <span>@StringLocalizer["{0} Status", statusFilterCount]</span>
}
else
{
@@ -71,13 +152,132 @@
}
</button>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "pending")" class="dropdown-item @(HasArrayFilter("status", "pending") ? "custom-active" : "")" text-translate="true">Pending</a>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "completed")" class="dropdown-item @(HasArrayFilter("status", "completed") ? "custom-active" : "")" text-translate="true">Settled</a>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "expired")" class="dropdown-item @(HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</a>
+ <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "pending")" class="dropdown-item @(HasArrayFilter("status", "pending") ? "custom-active" : "")" text-translate="true">Pending</a>
+ <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "completed")" class="dropdown-item @(HasArrayFilter("status", "completed") ? "custom-active" : "")" text-translate="true">Settled</a>
+ <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "expired")" class="dropdown-item @(HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</a>
<div role="separator" class="dropdown-divider"></div>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Archived</a>
+ <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Include Archived</a>
</div>
</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>
+ }
+ }
+ else
+ {
+ <span text-translate="true">All Time</span>
+ }
+ </button>
+ <div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
+ <a asp-action="GetPaymentRequests"
+ asp-route-storeId="@storeId"
+ asp-route-count="@Model.Count"
+ asp-route-searchText="@Model.SearchText"
+ asp-route-searchTerm="@Model.Search.Toggle("startdate", "-1d")"
+ class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")"
+ text-translate="true">
+ Last 24 hours
+ </a>
+ <a asp-action="GetPaymentRequests"
+ asp-route-storeId="@storeId"
+ asp-route-count="@Model.Count"
+ asp-route-searchText="@Model.SearchText"
+ asp-route-searchTerm="@Model.Search.Toggle("startdate", "-3d")"
+ class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")"
+ text-translate="true">
+ Last 3 days
+ </a>
+ <a asp-action="GetPaymentRequests"
+ asp-route-storeId="@storeId"
+ asp-route-count="@Model.Count"
+ asp-route-searchText="@Model.SearchText"
+ asp-route-searchTerm="@Model.Search.Toggle("startdate", "-7d")"
+ 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>
+ @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 (string.IsNullOrEmpty(Model.LabelFilter))
+ {
+ <span text-translate="true">All Labels</span>
+ }
+ else
+ {
+ <span text-translate="true">Label:</span>
+ <span>@Model.LabelFilter</span>
+ }
+ </button>
+
+ <ul class="dropdown-menu" aria-labelledby="LabelOptionsToggle">
+ @foreach (var label in Model.Labels)
+ {
+ <li>
+ <a asp-route-labelFilter="@label.Text"
+ asp-route-searchText="@Model.SearchText"
+ class="dropdown-item transaction-label-text@(Model.LabelFilter == label.Text ? " active" : string.Empty)"
+ style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
+ @label.Text
+ </a>
+ </li>
+ }
+
+ @if (!string.IsNullOrEmpty(Model.LabelFilter))
+ {
+ <li><hr class="dropdown-divider" /></li>
+ <li>
+ <a asp-route-labelFilter=""
+ class="dropdown-item text-danger"
+ text-translate="true">
+ Clear label filter
+ </a>
+ </li>
+ }
+ </ul>
+ </div>
+ }
+ @if (hasAnyFilter)
+ {
+ <button id="clearAllFiltersBtn"
+ type="button"
+ class="btn btn-secondary ms-auto"
+ style="min-width: 7rem;"
+ title="@StringLocalizer["Clear all filters"]">
+ <span class="align-middle" text-translate="true">Clear All</span>
+ </button>
+ }
</form>
@if (Model.Items.Any())
@@ -85,35 +285,56 @@
<div class="table-responsive-md">
<table class="table table-hover">
<thead>
- <tr>
- <th text-translate="true">Title</th>
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Expiry</span>
- <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
- <vc:icon symbol="time" />
- </button>
- </div>
- </th>
- <th text-translate="true">Reference Id</th>
- <th text-translate="true">Status</th>
- <th class="amount-col" text-translate="true">Amount</th>
- <th></th>
- </tr>
+ <tr>
+ <th class="date-col">
+ <div class="d-flex align-items-center gap-3">
+ <span text-translate="true">Date</span>
+ <button type="button"
+ class="btn btn-link p-0 switch-time-format only-for-js"
+ title="@StringLocalizer["Switch date format"]">
+ <vc:icon symbol="time" />
+ </button>
+ </div>
+ </th>
+ <th text-translate="true">Title</th>
+ <th text-translate="true">Id</th>
+ <th text-translate="true">Labels</th>
+ <th class="date-col">
+ <div class="d-flex align-items-center gap-3">
+ <span text-translate="true">Expiry</span>
+ <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
+ <vc:icon symbol="time" />
+ </button>
+ </div>
+ </th>
+ <th text-translate="true">Status</th>
+ <th class="amount-col" text-translate="true">Amount</th>
+ <th></th>
+ </tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr class="mass-action-row">
+ <td class="date-col">
+ @item.Created.ToBrowserDate()
+ </td>
<td>
<a asp-action="EditPaymentRequest" asp-route-storeId="@item.StoreId" asp-route-payReqId="@item.Id" id="Edit-@item.Id">@item.Title</a>
</td>
- <td class="date-col">
- @(item.ExpiryDate?.ToBrowserDate() ?? new HtmlString("<span class=\"text-muted\">No Expiry</span>"))
- </td>
<td>
@item.ReferenceId
</td>
+ <td>
+ <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" />
+ </td>
+ <td class="date-col">
+ @(item.ExpiryDate?.ToBrowserDate() ?? new HtmlString("<span class=\"text-muted\">No Expiry</span>"))
+ </td>
<td>
<span class="badge badge-@item.Status.ToLower() status-badge">@item.Status</span>
</td>
@@ -133,10 +354,10 @@
<vc:icon symbol="dots" />
</button>
<ul class="dropdown-menu" aria-labelledby="actionDropdown">
- <li><a class="dropdown-item" permission="@Policies.CanViewInvoices" asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@item.StoreId" asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(item.Id)}")" text-translate="true">Invoices</a></li>
- <li><a class="dropdown-item" permission="@Policies.CanModifyPaymentRequests" asp-action="ClonePaymentRequest" asp-route-storeId="@item.StoreId" asp-route-payReqId="@item.Id" id="Clone-@item.Id" text-translate="true">Clone</a></li>
+ <li><a class="dropdown-item" permission="@Policies.CanViewInvoices" asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@item.StoreId" asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(item.Id)}")" text-translate="true">Invoices</a></li>
+ <li><a class="dropdown-item" permission="@Policies.CanModifyPaymentRequests" asp-action="ClonePaymentRequest" asp-route-storeId="@item.StoreId" asp-route-payReqId="@item.Id" id="Clone-@item.Id" text-translate="true">Clone</a></li>
<li class="dropdown-divider"></li>
- <li><a class="dropdown-item" permission="@Policies.CanModifyPaymentRequests" asp-action="TogglePaymentRequestArchival" asp-route-storeId="@item.StoreId" asp-route-payReqId="@item.Id" id="ToggleArchival-@item.Id">@(item.Archived ? "Unarchive" : "Archive")</a></li>
+ <li><a class="dropdown-item" permission="@Policies.CanModifyPaymentRequests" asp-action="TogglePaymentRequestArchival" asp-route-storeId="@item.StoreId" asp-route-payReqId="@item.Id" id="ToggleArchival-@item.Id">@(item.Archived ? "Unarchive" : "Archive")</a></li>
</ul>
</div>
</div>
@@ -155,3 +376,57 @@ else
There are no payment requests matching your criteria.
</p>
}
+
+@section PageFootContent {
+ <script src="~/modal/btcpay.js" asp-append-version="true" async></script>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", function () {
+ const clearBtn = document.getElementById('clearAllFiltersBtn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', function () {
+ const form = clearBtn.closest('form');
+ if (!form) return;
+
+ const searchText = form.querySelector('[name="SearchText"]');
+ if (searchText) searchText.value = '';
+
+ const searchTerm = form.querySelector('[name="SearchTerm"]');
+ if (searchTerm) searchTerm.value = '';
+
+ form.submit();
+ });
+ }
+ });
+
+ $('#btnCustomRangeDate').on('click', function () {
+ var filterString = "";
+
+ var dtpStartDate = $("#dtpStartDate").val();
+ if (dtpStartDate) {
+ filterString = "startdate%3A" + dtpStartDate;
+ }
+
+ var dtpEndDate = $("#dtpEndDate").val();
+ if (dtpEndDate) {
+ if (filterString !== "") {
+ filterString += ",";
+ }
+ filterString += "enddate%3A" + dtpEndDate;
+ }
+
+ if (filterString) {
+ var baseUrl = "@Url.Action("GetPaymentRequests", new { storeId = storeId })";
+ var redirectUri = baseUrl +
+ "?Count=" + $("#Count").val() +
+ "&timezoneoffset=" + $("#TimezoneOffset").val() +
+ "&SearchTerm=" + filterString +
+ "&SearchText=" + $('input[name="SearchText"]').val();
+
+ window.location.href = redirectUri;
+ } else {
+ $("#dtpStartDate").next().trigger("focus");
+ }
+ });
+ </script>
+}
Why this scored 17/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.