Unify reports page time range selection (#7438)
What changed, and why it matters
This commit is a routine UI/UX refactor of the BTCPay Server reports page. It replaces a custom date-picker and JavaScript-driven report fetching with a shared date-range selector component and standard GET form submission. There is no indication of a security fix or vulnerability being addressed.
No security action required; treat as normal feature/refactor commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change unifies time-range selection across reports by removing the client-side StoreReportRequest/TimePeriod model and the dedicated StoreReportsJson POST endpoint, and instead using the existing SearchString-based date-range filters and a server-rendered GET form. It also improves timezone selector discoverability. The Greenfield API reports endpoint remains marked [NonAction] (disabled). No security-relevant code paths such as authorization, input validation, or output encoding appear to have changed in a way that fixes a vulnerability.
Changed components
BTCPayServer/Controllers/UIReportsController.csBTCPayServer/Controllers/GreenField/GreenfieldReportsController.csBTCPayServer/Views/UIReports/StoreReports.cshtmlBTCPayServer/Components/DateRangeSelectorBTCPayServer/wwwroot/js/store-reports.jsInspect captured patch +212 / −289
diff --git a/BTCPayServer.Client/Models/StoreReportRequest.cs b/BTCPayServer.Client/Models/StoreReportRequest.cs
deleted file mode 100644
index 583f3b9..0000000
--- a/BTCPayServer.Client/Models/StoreReportRequest.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Client.Models;
-
-public class StoreReportRequest
-{
- public string ViewName { get; set; }
- public TimePeriod TimePeriod { get; set; }
-}
-public class StoreReportResponse
-{
- public class Field
- {
- public Field()
- {
-
- }
- public Field(string name, string type)
- {
- Name = name;
- Type = type;
- }
- public string Name { get; set; }
- public string Type { get; set; }
- }
- public IList<Field> Fields { get; set; } = new List<Field>();
- public List<JArray> Data { get; set; }
- public DateTimeOffset From { get; set; }
- public DateTimeOffset To { get; set; }
- public List<ChartDefinition> Charts { get; set; }
-
- public int GetIndex(string fieldName)
- {
- return Fields.ToList().FindIndex(f => f.Name == fieldName);
- }
-}
-
-public class ChartDefinition
-{
- public string Name { get; set; }
-
- public List<string> Groups { get; set; } = new List<string>();
- public List<string> Totals { get; set; } = new List<string>();
- public bool HasGrandTotal { get; set; }
- public List<string> Aggregates { get; set; } = new List<string>();
- public List<string> Filters { get; set; } = new List<string>();
-}
-
-public class TimePeriod
-{
- [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
- public DateTimeOffset? From { get; set; }
- [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
- public DateTimeOffset? To { get; set; }
-}
diff --git a/BTCPayServer.Client/Models/StoreReportResponse.cs b/BTCPayServer.Client/Models/StoreReportResponse.cs
new file mode 100644
index 0000000..52765cb
--- /dev/null
+++ b/BTCPayServer.Client/Models/StoreReportResponse.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class StoreReportResponse
+{
+ public class Field
+ {
+ public Field()
+ {
+
+ }
+ public Field(string name, string type)
+ {
+ Name = name;
+ Type = type;
+ }
+ public string Name { get; set; }
+ public string Type { get; set; }
+ }
+ public IList<Field> Fields { get; set; } = new List<Field>();
+ public List<JArray> Data { get; set; }
+ public DateTimeOffset From { get; set; }
+ public DateTimeOffset To { get; set; }
+ public List<ChartDefinition> Charts { get; set; }
+
+ public int GetIndex(string fieldName)
+ {
+ return Fields.ToList().FindIndex(f => f.Name == fieldName);
+ }
+}
+
+public class ChartDefinition
+{
+ public string Name { get; set; }
+
+ public List<string> Groups { get; set; } = new List<string>();
+ public List<string> Totals { get; set; } = new List<string>();
+ public bool HasGrandTotal { get; set; }
+ public List<string> Aggregates { get; set; } = new List<string>();
+ public List<string> Filters { get; set; } = new List<string>();
+}
diff --git a/BTCPayServer.Tests/Extensions.cs b/BTCPayServer.Tests/Extensions.cs
index 5274dd0..6a76dbb 100644
--- a/BTCPayServer.Tests/Extensions.cs
+++ b/BTCPayServer.Tests/Extensions.cs
@@ -40,7 +40,7 @@ namespace BTCPayServer.Tests
public static async Task AssertNoError(this IPage page)
{
- await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await page.WaitForLoadStateAsync(LoadState.Load);
var pageSource = await page.ContentAsync();
if (pageSource.Contains("alert-danger"))
{
diff --git a/BTCPayServer.Tests/GlobalSearchTests.cs b/BTCPayServer.Tests/GlobalSearchTests.cs
index 3218b7b..16085a2 100644
--- a/BTCPayServer.Tests/GlobalSearchTests.cs
+++ b/BTCPayServer.Tests/GlobalSearchTests.cs
@@ -88,6 +88,7 @@ public class GlobalSearchTests(ITestOutputHelper helper) : UnitTestBase(helper)
foreach (var item in items.Where(item => !string.IsNullOrEmpty(item.Url)))
{
+ TestLogs.LogInformation($"Navigating to {item.Url}");
await s.GoToUrl(item.Url);
await s.Page.AssertNoError();
}
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index a66f1da..ec98345 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -922,7 +922,7 @@ namespace BTCPayServer.Tests
public async Task AssertPageAccess(bool shouldHaveAccess, string url)
{
await GoToUrl(url);
- await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+ await Page.WaitForLoadStateAsync(LoadState.Load);
var content = await Page.ContentAsync();
Assert.DoesNotContain("404 - Page not found", content);
if (shouldHaveAccess)
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 5663b2e..7c6ce47 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1444,15 +1444,9 @@ namespace BTCPayServer.Tests
// Report
await s.Page.ClickAsync("#view-report");
- await s.Page.WaitForLoadStateAsync();
- Assert.Contains("/reports", s.Page.Url);
- var requestsTabClasses = await s.Page.GetAttributeAsync("#SectionNav a[data-view='Requests']", "class");
- Assert.NotNull(requestsTabClasses);
- Assert.Contains("active", requestsTabClasses);
- await Expect(s.Page.Locator("#fromDate")).ToBeVisibleAsync();
- await Expect(s.Page.Locator("#toDate")).ToBeVisibleAsync();
- var reportHtml = await s.Page.ContentAsync();
- Assert.Contains("\"viewName\":\"Requests\"", reportHtml);
+ await Expect(s.Page.Locator("#ReportViewOptionsToggle")).ToContainTextAsync("Requests");
+ await Expect(s.Page.Locator("#DateRangeSelector")).ToContainTextAsync("This month");
+ await Expect(s.Page.Locator("#ReportViewOptionsToggle")).ToContainTextAsync("Requests");
await s.Page.WaitForSelectorAsync("#app table tbody tr");
await Expect(s.Page.Locator("#app table tbody tr").Filter(new LocatorFilterOptions { HasText = "Payment Request" })).ToHaveCountAsync(2);
}
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
index 585fb9e..3fa10fc 100644
--- a/BTCPayServer.Tests/SubscriptionTests.cs
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -279,12 +279,17 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
}
await s.GoToStore(s.StoreId);
await s.GoToStore(s.StoreId, StoreNavPages.Reporting);
- await s.Page.ClickAsync("a[data-view='Subscribers']");
- await s.Page.ClickAsync("#searchBtn");
+
+ await Expect(s.Page.Locator("#ReportViewOptionsToggle")).ToHaveTextAsync("Report: Invoices");
+ await s.Page.ClickAsync("#ReportViewOptionsToggle");
+ await s.Page.ClickAsync("#ReportViewDropdown button:has-text('Subscribers')");
+
+ await Expect(s.Page.Locator("#ReportViewOptionsToggle")).ToHaveTextAsync("Report: Subscribers");
await s.Page.WaitForSelectorAsync("#raw-data-table table");
- await s.Page.ClickAsync("a[data-view='Credit History']");
- await s.Page.ClickAsync("#searchBtn");
+ await s.Page.ClickAsync("#ReportViewOptionsToggle");
+ await s.Page.ClickAsync("#ReportViewDropdown button:has-text('Credit History')");
+ await Expect(s.Page.Locator("#ReportViewOptionsToggle")).ToHaveTextAsync("Report: Credit History");
await s.Page.WaitForSelectorAsync("#raw-data-table table");
}
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index c635a3d..3d08e01 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -18,6 +18,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
+using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Fido2;
@@ -2884,7 +2885,7 @@ namespace BTCPayServer.Tests
await acc.CreateLNAddress();
await acc.PayOnLNAddress();
- var report = await GetReport(acc, new() { ViewName = "Payments" });
+ var report = await GetReport(acc, "Payments");
// 1 payment on LN Address
// 1 payment on LNURL
// 1 payment on BOLT11
@@ -2900,7 +2901,7 @@ namespace BTCPayServer.Tests
Assert.Single(paymentTypes["On-Chain"]);
// 2 on-chain transactions: It received from the cashcow, then paid its own invoice
- report = await GetReport(acc, new() { ViewName = "Wallets" });
+ report = await GetReport(acc, "Wallets");
var txIdIndex = report.GetIndex("TransactionId");
var balanceIndex = report.GetIndex("BalanceChange");
Assert.Equal(2, report.Data.Count);
@@ -2908,7 +2909,7 @@ namespace BTCPayServer.Tests
Assert.Contains(report.Data, d => d[balanceIndex]["v"].Value<decimal>() == 1.0m);
// Items sold
- report = await GetReport(acc, new() { ViewName = "Sales" });
+ report = await GetReport(acc, "Sales");
var itemIndex = report.GetIndex("Product");
var countIndex = report.GetIndex("Quantity");
var itemsCount = report.Data.GroupBy(d => d[itemIndex].Value<string>())
@@ -2918,7 +2919,7 @@ namespace BTCPayServer.Tests
await acc.ImportOldInvoices();
var date2018 = new DateTimeOffset(2018, 1, 1, 0, 0, 0, TimeSpan.Zero);
- report = await GetReport(acc, new() { ViewName = "Payments", TimePeriod = new TimePeriod() { From = date2018, To = date2018 + TimeSpan.FromDays(365) } });
+ report = await GetReport(acc, "Payments", date2018, date2018 + TimeSpan.FromDays(365));
var invoiceIdIndex = report.GetIndex("InvoiceId");
var invoiceCurrencyAmountIndex = report.GetIndex("InvoiceCurrencyAmount");
var rateIndex = report.GetIndex("Rate");
@@ -2940,7 +2941,7 @@ namespace BTCPayServer.Tests
async Task AssertData(string currency, decimal awaiting, decimal limit, decimal completed, bool fullyPaid)
{
- report = await GetReport(acc, new() { ViewName = "Refunds" });
+ report = await GetReport(acc, "Refunds");
var currencyIndex = report.GetIndex("Currency");
var awaitingIndex = report.GetIndex("Awaiting");
var fullyPaidIndex = report.GetIndex("FullyPaid");
@@ -2978,10 +2979,16 @@ namespace BTCPayServer.Tests
return Math.Round(jobj["v"].Value<decimal>(), jobj["d"].Value<int>());
}
- private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
+ private async Task<StoreReportResponse> GetReport(TestAccount acc, string viewName, DateTimeOffset? from = null, DateTimeOffset? to = null)
{
- var controller = acc.GetController<UIReportsController>();
- return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType<OkObjectResult>()
+ var search = new SearchString(null);
+ search.SetFilter("view", viewName);
+ if (from is not null)
+ search.SetFilter("startdate", from.Value.ToString("O", CultureInfo.InvariantCulture));
+ if (to is not null)
+ search.SetFilter("enddate", to.Value.ToString("O", CultureInfo.InvariantCulture));
+ var controller = acc.GetController<GreenfieldReportsController>();
+ return (await controller.StoreReports(acc.StoreId, search)).AssertType<OkObjectResult>()
.Value
.AssertType<StoreReportResponse>();
}
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index 70ab9f2..012b076 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -1165,6 +1165,11 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ClickAsync("#DateRangeSelector");
var browserTimeZone = await s.Page.EvaluateAsync<string>("() => Intl.DateTimeFormat().resolvedOptions().timeZone");
await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(browserTimeZone + " (Default)");
+ await s.Page.ClickAsync("#DateRangeTimeZone");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(string.Empty);
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveAttributeAsync("placeholder", browserTimeZone + " (Default)");
+ await s.Page.PressAsync("#DateRangeTimeZone", "Tab");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(browserTimeZone + " (Default)");
const string selectedTimeZone = "America/New_York";
await s.Page.FillAsync("#DateRangeTimeZone", selectedTimeZone);
diff --git a/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
index bad9026..f9de927 100644
--- a/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
+++ b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
@@ -11,11 +11,13 @@ public class DateRangeSelector : ViewComponent
{
public IViewComponentResult Invoke(
SearchString search,
- string? customRangeTitle = null)
+ string? customRangeTitle = null,
+ bool showAllTime = true)
=> View(new DateRangeSelectorModel
{
Search = search ?? throw new ArgumentNullException(nameof(search)),
CustomRangeTitle = customRangeTitle ?? "Filter by Custom Range",
+ ShowAllTime = showAllTime
});
}
@@ -55,6 +57,7 @@ public class DateRangeSelectorModel
public string? StartDateInputValue => FormatInputDate(LocalDate(DateRange.StartDate));
public string? EndDateInputValue => FormatInputDate(LocalDate(DateRange.EndDate));
+ public bool ShowAllTime { get; set; }
private DateTimeOffset? LocalDate(DateTimeOffset? d)
{
diff --git a/BTCPayServer/Components/DateRangeSelector/Default.cshtml b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
index f72a942..0c7c5be 100644
--- a/BTCPayServer/Components/DateRangeSelector/Default.cshtml
+++ b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
@@ -72,8 +72,11 @@
</datalist>
</div>
<hr class="dropdown-divider" />
- <button type="submit" name="FilterCommand" value="alltime" class="dropdown-item @(!Model.HasDateFilter ? "custom-active" : string.Empty)" text-translate="true">All Time</button>
- <hr class="dropdown-divider" />
+ @if (Model.ShowAllTime)
+ {
+ <button type="submit" name="FilterCommand" value="alltime" class="dropdown-item @(!Model.HasDateFilter ? "custom-active" : string.Empty)" text-translate="true">All Time</button>
+ <hr class="dropdown-divider" />
+ }
@foreach (var filter in filters)
{
<button type="submit" name="FilterCommand" value="set-daterange:@filter.DateRange" class="dropdown-item @(Model.HasDateRange(@filter.DateRange) ? "custom-active" : string.Empty)" text-translate="true">@filter.Label</button>
@@ -150,6 +153,20 @@
dateRangeTimeZone.value = defaultOption.value;
}
+ // Show all timezone options on focus instead of filtering by the current value.
+ // Keep the current value as a placeholder and restore it if no change is made.
+ dateRangeTimeZone.addEventListener('focus', function () {
+ this.placeholder = this.value;
+ this.value = '';
+ });
+
+ dateRangeTimeZone.addEventListener('blur', function () {
+ if (!this.value) {
+ this.value = this.placeholder;
+ }
+ this.removeAttribute('placeholder');
+ });
+
function updateTimeZone() {
if (dateRangeTimeZone.value === defaultValue || dateRangeTimeZone.value === "") {
tzSubmit.value = "set-timezone:" + browserTz;
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldReportsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldReportsController.cs
index aa94ce4..cf11009 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldReportsController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldReportsController.cs
@@ -31,17 +31,15 @@ public class GreenfieldReportsController(
[Authorize(Policy = Policies.CanViewReports, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/reports")]
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
- public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
+ public async Task<IActionResult> StoreReports(string storeId, SearchString? search = null, CancellationToken cancellationToken = default)
{
- vm ??= new StoreReportRequest();
- vm.ViewName ??= DefaultReport;
- vm.TimePeriod ??= new TimePeriod();
- vm.TimePeriod.To ??= DateTime.UtcNow;
- vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
- var from = vm.TimePeriod.From.Value;
- var to = vm.TimePeriod.To.Value;
+ search ??= new SearchString(null);
+ var viewName = search.GetFilterString("view") ?? DefaultReport;
+ var range = search.GetDateRange(TimeZoneInfo.Utc);
+ var to = range.EndDate ?? DateTimeOffset.UtcNow;
+ var from = range.StartDate ?? to.AddMonths(-1);
- if (ReportService.ReportProviders.TryGetValue(vm.ViewName, out var report))
+ if (ReportService.ReportProviders.TryGetValue(viewName, out var report))
{
if (!report.IsAvailable())
return this.CreateAPIError(503, "view-unavailable", $"This view is unavailable at this moment");
@@ -60,7 +58,7 @@ public class GreenfieldReportsController(
return Ok(result);
}
- ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
+ ModelState.AddModelError("view", "View doesn't exist");
return this.CreateValidationError(ModelState);
}
@@ -81,4 +79,3 @@ public class GreenfieldReportsController(
}
}
}
-
diff --git a/BTCPayServer/Controllers/UIReportsController.cs b/BTCPayServer/Controllers/UIReportsController.cs
index a7ddee9..eacbd91 100644
--- a/BTCPayServer/Controllers/UIReportsController.cs
+++ b/BTCPayServer/Controllers/UIReportsController.cs
@@ -1,4 +1,5 @@
#nullable enable
+using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -44,39 +45,36 @@ public partial class UIReportsController : Controller
public ApplicationDbContextFactory DBContextFactory { get; }
public TransactionLinkProviders TransactionLinkProviders { get; }
- [HttpPost("stores/{storeId}/reports")]
- [AcceptMediaTypeConstraint("application/json")]
- [Authorize(Policy = Policies.CanViewReports, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- [IgnoreAntiforgeryToken]
- public async Task<IActionResult> StoreReportsJson(string storeId, [FromBody] StoreReportRequest? request = null, bool fakeData = false, CancellationToken cancellation = default)
- {
- var result = await Api.StoreReports(storeId, request, cancellation);
- if (fakeData && Env.CheatMode)
- {
- var r = (StoreReportResponse)((JsonResult)result!).Value!;
- r.Data = Generate(r.Fields).Select(r => new JArray(r)).ToList();
- }
- return result;
- }
-
[HttpGet("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("text/html")]
[Authorize(Policy = Policies.CanViewReports, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult StoreReports(
+ public async Task<IActionResult> StoreReports(
string storeId,
- string ? viewName = null)
+ StoreReportsViewModel? model = null,
+ bool fakeData = false,
+ CancellationToken cancellation = default)
{
- var vm = new StoreReportsViewModel
- {
- InvoiceTemplateUrl = Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
- ExplorerTemplateUrls = TransactionLinkProviders.ToDictionary(p => p.Key, p => p.Value.BlockExplorerLink?.Replace("{0}", "TX_ID")),
- Request = new StoreReportRequest { ViewName = viewName ?? GreenfieldReportsController.DefaultReport },
- AvailableViews = ReportService.ReportProviders
- .Values
- .Where(r => r.IsAvailable())
- .Select(k => k.Name)
- .OrderBy(k => k).ToList()
- };
- return View(vm);
+ model ??= new StoreReportsViewModel();
+ var search = model.GetSearch();
+ if (model.FilterCommand is not null)
+ return model.Redirect(Request);
+ if (search.GetExplicitTimeZone() is null)
+ return View(model);
+ var result = await Api.StoreReports(storeId, search, cancellation);
+ if (result is not ObjectResult { Value: StoreReportResponse reportResponse })
+ return result;
+
+ if (fakeData && Env.CheatMode)
+ reportResponse.Data = Generate(reportResponse.Fields).Select(r => new JArray(r)).ToList();
+
+ model.InvoiceTemplateUrl = Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" });
+ model.ExplorerTemplateUrls = TransactionLinkProviders.ToDictionary(p => p.Key, p => p.Value.BlockExplorerLink?.Replace("{0}", "TX_ID"));
+ model.AvailableViews = ReportService.ReportProviders
+ .Values
+ .Where(r => r.IsAvailable())
+ .Select(k => k.Name)
+ .OrderBy(k => k).ToList();
+ model.Result = reportResponse;
+ return View(model);
}
}
diff --git a/BTCPayServer/Models/StoreReportsViewModels/StoreReportsViewModel.cs b/BTCPayServer/Models/StoreReportsViewModels/StoreReportsViewModel.cs
index 8e203be..fa13a5b 100644
--- a/BTCPayServer/Models/StoreReportsViewModels/StoreReportsViewModel.cs
+++ b/BTCPayServer/Models/StoreReportsViewModels/StoreReportsViewModel.cs
@@ -1,14 +1,29 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
+using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Payments;
namespace BTCPayServer.Models.StoreReportsViewModels;
-public class StoreReportsViewModel
+public class StoreReportsViewModel : BasePagingViewModel
{
- public string InvoiceTemplateUrl { get; set; }
- public Dictionary<PaymentMethodId,string> ExplorerTemplateUrls { get; set; }
- public StoreReportRequest Request { get; set; }
- public List<string> AvailableViews { get; set; }
+ public string InvoiceTemplateUrl { get; set; } = "";
+ public Dictionary<PaymentMethodId,string> ExplorerTemplateUrls { get; set; } = [];
+ public List<string> AvailableViews { get; set; } = [];
public StoreReportResponse Result { get; set; }
+ public string ViewName { get; set; }
+ public override int CurrentPageCount => Result?.Data?.Count ?? 0;
+
+ protected override void AddUIFilters(SearchString search)
+ {
+ base.AddUIFilters(search);
+ if (!search.ContainsFilter("view"))
+ search.SetFilter("view", ViewName ?? GreenfieldReportsController.DefaultReport);
+ // By default, search should be thismonth
+ if (FilterCommand != "alltime")
+ {
+ if (!search.HasArrayFilter("startdate") && !search.HasArrayFilter("enddate") && !search.HasArrayFilter("daterange"))
+ search.SetDateRange("thismonth");
+ }
+ }
}
diff --git a/BTCPayServer/TimeZones.cs b/BTCPayServer/TimeZones.cs
index f090c65..195d3e7 100644
--- a/BTCPayServer/TimeZones.cs
+++ b/BTCPayServer/TimeZones.cs
@@ -16,8 +16,10 @@ public static class TimeZones
zones = TimeZoneInfo.GetSystemTimeZones().ToDictionary(t => t.Id, t => t, StringComparer.InvariantCultureIgnoreCase);
abbreviations = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
{
- ["UTC"] = "Etc/UTC",
- ["GMT"] = "Etc/UTC",
+ ["Etc/Unknown"] = "UTC",
+ ["Etc/UTC"] = "UTC",
+ ["Etc/GMT"] = "UTC",
+ ["GMT"] = "UTC",
["JST"] = "Asia/Tokyo",
["KST"] = "Asia/Seoul",
["HKT"] = "Asia/Hong_Kong",
@@ -42,6 +44,8 @@ public static class TimeZones
public static bool TryGet(string id, [MaybeNullWhen(false)] out TimeZoneInfo zone)
{
+ if (zones.TryGetValue(id, out zone))
+ return true;
abbreviations.TryGetValue(id, out var fullName);
return zones.TryGetValue(fullName ?? id, out zone);
}
diff --git a/BTCPayServer/Views/UIReports/StoreReports.cshtml b/BTCPayServer/Views/UIReports/StoreReports.cshtml
index 55f90eb..c5efe03 100644
--- a/BTCPayServer/Views/UIReports/StoreReports.cshtml
+++ b/BTCPayServer/Views/UIReports/StoreReports.cshtml
@@ -6,6 +6,7 @@
@{
ViewData.SetLayoutModel(new(nameof(StoreNavPages.Reporting), StringLocalizer["Reporting"]));
Csp.UnsafeEval();
+ var selectedView = Model.Search.GetFilterString("view") ?? Model.AvailableViews.FirstOrDefault();
}
@section PageHeadContent
@@ -25,9 +26,6 @@
flex: 1 1 450px;
}
- main .dropdown-menu.show {
- z-index: 99999;
- }
</style>
}
@@ -41,48 +39,33 @@
</h2>
<div>
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true"
- asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
+ asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText">Create fake data</a>
<button id="page-primary" class="btn btn-primary text-nowrap" type="button" data-action="exportCSV" text-translate="true">Export</button>
</div>
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
- <nav id="SectionNav">
- <div class="nav">
- @foreach (var v in Model.AvailableViews)
- {
- <a href="#" data-view="@v" class="available-view nav-link @(Model.Request.ViewName == v ? "active" : "")" role="tab">@v</a>
- }
- </div>
- </nav>
-
- <div class="d-flex gap-3">
- <div class="form-group">
- <label for="fromDate" class="form-label">@StringLocalizer["Start Date"]</label>
- <input id="fromDate" name="fromDate"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
- class="form-control flatdtpicker" placeholder="@StringLocalizer["Start Date"]" />
- </div>
-
- <div class="form-group">
- <label for="toDate" class="form-label">@StringLocalizer["End Date"]</label>
- <input id="toDate" name="toDate" class="form-control flatdtpicker"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
- placeholder="@StringLocalizer["End Date"]" />
- </div>
- <div id="searchGroup" v-cloak class="form-group d-flex align-items-end">
- <button id="searchBtn" class="btn btn-primary" :disabled="loading" type="button">
- <span v-if="loading" class="spinner-border spinner-border-sm me-1" role="status" style="margin-left: -6px;"></span>
- <span v-else class="me-1"><vc:icon symbol="actions-search" /></span>
- <span text-translate="true">Search</span>
+ <form id="StoreReportsSearch" class="d-flex flex-wrap align-items-center gap-3 mb-4" asp-action="StoreReports" method="get">
+ <button id="StoreReportsSearchSubmit" type="submit" name="FilterCommand" class="d-none"></button>
+ <input id="SearchTerm" name="SearchTerm" type="hidden" value="@Model.SearchTerm" />
+ <input name="SearchText" type="hidden" value="@Model.SearchText" />
+ <div class="dropdown" id="ReportViewDropdown">
+ <button id="ReportViewOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @StringLocalizer["Report: {0}", selectedView ?? ""]
</button>
- <span class="text-danger invalid-feedback field-validation-error" v-if="error">{{ error }}</span>
+ <div class="dropdown-menu" aria-labelledby="ReportViewOptionsToggle">
+ @foreach (var v in Model.AvailableViews)
+ {
+ <button type="submit" name="FilterCommand" value="set:view=@v" class="dropdown-item @(selectedView == v ? "custom-active" : string.Empty)">@v</button>
+ }
+ </div>
</div>
- </div>
+ <vc:date-range-selector search="Model.Search" show-all-time="false" custom-range-title='@StringLocalizer["Filter reports by Custom Range"].Value'></vc:date-range-selector>
+ </form>
</div>
</div>
-<div id="app" v-if="!loading" v-cloak class="w-100-fixed">
+<div id="app" v-cloak class="w-100-fixed">
<div v-if="srv.charts && srv.charts.some(hasChartData)" id="charts" class="d-flex flex-wrap mb-3">
<article v-for="chart in srv.charts" v-if="hasChartData(chart)">
<h3>{{ chart.name }}</h3>
@@ -202,7 +185,25 @@
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
- <script>const srv = @Safe.Json(Model);</script>
+ <script>
+ const srv = @Safe.Json(new
+ {
+ invoiceTemplateUrl = Model.InvoiceTemplateUrl,
+ explorerTemplateUrls = Model.ExplorerTemplateUrls,
+ result = Model.Result
+ });
+ </script>
+ @if (Model.Search.GetExplicitTimeZone() is null)
+ {
+ <script>
+ document.addEventListener('DOMContentLoaded', () => {
+ const timezone = getDateFormatter().resolvedOptions().timeZone;
+ const submitButton = document.getElementById('StoreReportsSearchSubmit');
+ submitButton.value = "set-timezone:" + timezone;
+ submitButton.closest('form').requestSubmit(submitButton);
+ });
+ </script>
+ }
<script src="~/js/datatable.js" asp-append-version="true"></script>
<script src="~/js/store-reports.js" asp-append-version="true"></script>
}
diff --git a/BTCPayServer/wwwroot/js/store-reports.js b/BTCPayServer/wwwroot/js/store-reports.js
index 634335a..0232200 100644
--- a/BTCPayServer/wwwroot/js/store-reports.js
+++ b/BTCPayServer/wwwroot/js/store-reports.js
@@ -1,4 +1,4 @@
-let app, searchBtnApp, origData;
+let app, origData;
srv.sortBy = function (field, event) {
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
@@ -82,61 +82,21 @@ srv.updateFieldViews = function () {
}
};
document.addEventListener("DOMContentLoaded", () => {
- delegate("click", "#searchBtn", function () {
- fetchStoreReports();
- })
- delegate("input", ".flatdtpicker", function () {
- // We don't use vue to bind dates, because VueJS break the flatpickr as soon as binding occurs.
- let to = document.getElementById("toDate").value
- let from = document.getElementById("fromDate").value
-
- if (!to || !from)
- return;
-
- from = moment(from).unix();
- to = moment(to).endOf('day').unix();
-
- srv.request.timePeriod.from = from;
- srv.request.timePeriod.to = to;
- });
-
delegate("click", "[data-action='exportCSV']", downloadCSV);
-
- delegate("click", ".available-view", function (e) {
- e.preventDefault();
- const {view} = e.target.dataset;
- document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("active"));
- e.target.classList.add("active");
- srv.request.viewName = view;
- fetchStoreReports(true)
- });
-
- let to = new Date();
- let from = new Date(to.getTime() - 1000 * 60 * 60 * 24 * 30);
- var urlParams = new URLSearchParams(new URL(window.location).search);
- if (urlParams.has("from")) {
- from = new Date(parseInt(urlParams.get("from")) * 1000);
+ srv.result = srv.result || {fields: [], data: [], charts: []};
+ srv.dataUpdated();
+ modifyFields(srv.result.fields, srv.result.data, 'datetime', a => a ? moment(a).format() : a);
+ srv.charts = [];
+ for (let i = 0; i < srv.result.charts.length; i++) {
+ const chart = srv.result.charts[i];
+ const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
+ table.name = chart.name;
+ srv.charts.push(table);
}
- if (urlParams.has("to")) {
- to = new Date(parseInt(urlParams.get("to")) * 1000);
- }
- srv.request = srv.request || {};
- srv.request.timePeriod = srv.request.timePeriod || {};
- srv.request.timePeriod.to = moment(to).unix();
- srv.request.viewName = srv.request.viewName || "Invoices";
- srv.request.timePeriod.from = moment(from).unix();
- srv.request.timeZone = getDateFormatter().resolvedOptions().timeZone;
- srv.result = {fields: [], values: []};
- searchBtnApp = new Vue({
- el: '#searchGroup',
- data() {
- return { loading: false, error: "" };
- },
- });
app = new Vue({
el: '#app',
data() {
- return {srv, loading: false};
+ return {srv};
},
methods: {
hasChartData(chart) {
@@ -151,8 +111,6 @@ document.addEventListener("DOMContentLoaded", () => {
displayDate
}
});
- updateUIDateRange();
- fetchStoreReports();
});
const dtFormatter = getDateFormatter();
@@ -169,11 +127,6 @@ function displayValue(val) {
return val && typeof val === "object" && typeof val.d === "number" ? new Decimal(val.v).toFixed(val.d) : val;
}
-function updateUIDateRange() {
- document.getElementById("toDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.to).toDate());
- document.getElementById("fromDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.from).toDate());
-}
-
// This function modify all the fields of a given type
function modifyFields(fields, data, type, action) {
const fieldIndices = fields
@@ -201,69 +154,6 @@ function downloadCSV() {
saveAs(blob, "export.csv");
}
-let fetchPromise = null;
-var abortFetching = new AbortController()
-
-function setLoading(val)
-{
- searchBtnApp.loading = val;
- app.loading = val;
-}
-async function fetchStoreReports(abort) {
- if (abort)
- {
- abortFetching.abort();
- }
- if (fetchPromise) {
- await fetchPromise;
- }
- abortFetching = new AbortController();
- fetchPromise = (async () => {
- setLoading(true);
- searchBtnApp.error = "";
- try {
- const result = await fetch(window.location, {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(srv.request),
- signal: abortFetching.signal
- });
-
- srv.result = await result.json();
- srv.dataUpdated();
- setLoading(false);
-
- // Dates from API are UTC, convert them to local time
- modifyFields(srv.result.fields, srv.result.data, 'datetime', a => a ? moment(a).format() : a);
- var urlParams = new URLSearchParams(new URL(window.location).search);
- urlParams.set("viewName", srv.request.viewName);
- urlParams.set("from", srv.request.timePeriod.from);
- urlParams.set("to", srv.request.timePeriod.to);
- history.replaceState(null, null, "?" + urlParams.toString());
- updateUIDateRange();
-
- srv.charts = [];
- for (let i = 0; i < srv.result.charts.length; i++) {
- const chart = srv.result.charts[i];
- const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
- table.name = chart.name;
- srv.charts.push(table);
- }
-
- app.srv = srv;
- } catch (e) {
- setLoading(false);
- if (e.name !== 'AbortError') {
- searchBtnApp.error = e.message;
- }
- }
- })();
- await fetchPromise;
-}
-
function getInvoiceUrl(value) {
if (!value)
return;
Why this scored 15/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.