diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index bd63c267c7..ff46a8fab3 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -1042,14 +1042,13 @@ public void CanUsePermission() [Fact] public void CanParseFilter() { + var storeId = "6DehZnc9S7qC6TUTNWuzJ1pFsHTHvES6An21r3MjvLey"; var filter = "storeid:abc, status:abed, blabhbalh "; var search = new SearchString(filter); Assert.Equal("storeid:abc, status:abed, blabhbalh", search.ToString()); Assert.Equal("blabhbalh", search.TextSearch); - Assert.Single(search.Filters["storeid"]); - Assert.Single(search.Filters["status"]); - Assert.Equal("abc", search.Filters["storeid"].First()); - Assert.Equal("abed", search.Filters["status"].First()); + Assert.Single(search.Filters["storeid"], "abc"); + Assert.Single(search.Filters["status"], "abed"); filter = "status:abed, status:abed2"; search = new SearchString(filter); @@ -1064,6 +1063,48 @@ public void CanParseFilter() search = new SearchString(filter); Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First()); Assert.Equal("hekki", search.TextSearch); + + // modify search + filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00"; + search = new SearchString(filter); + Assert.Equal(filter, search.ToString()); + Assert.Equal("fulltext searchterm", search.TextSearch); + Assert.Single(search.Filters["storeid"], storeId); + Assert.Single(search.Filters["status"], "settled"); + Assert.Single(search.Filters["exceptionstatus"], "paidLate"); + Assert.Single(search.Filters["unusual"], "true"); + + // toggle off bool with same value + var modified = new SearchString(search.Toggle("unusual", "true")); + Assert.Null(modified.GetFilterBool("unusual")); + + // add to array + modified = new SearchString(modified.Toggle("status", "processing")); + var statusArray = modified.GetFilterArray("status"); + Assert.Equal(2, statusArray.Length); + Assert.Contains("processing", statusArray); + Assert.Contains("settled", statusArray); + + // toggle off array with same value + modified = new SearchString(modified.Toggle("status", "settled")); + statusArray = modified.GetFilterArray("status"); + Assert.Single(statusArray, "processing"); + + // toggle off array with null value + modified = new SearchString(modified.Toggle("status", null)); + Assert.Null(modified.GetFilterArray("status")); + + // toggle off date with null value + modified = new SearchString(modified.Toggle("startdate", "-7d")); + Assert.Single(modified.GetFilterArray("startdate"), "-7d"); + modified = new SearchString(modified.Toggle("startdate", null)); + Assert.Null(modified.GetFilterArray("startdate")); + + // toggle off date with same value + modified = new SearchString(modified.Toggle("enddate", "-7d")); + Assert.Single(modified.GetFilterArray("enddate"), "-7d"); + modified = new SearchString(modified.Toggle("enddate", "-7d")); + Assert.Null(modified.GetFilterArray("enddate")); } [Fact] diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 4d6057e201..d5cd60dd20 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -720,8 +720,8 @@ public async Task CanCreateStores() Assert.DoesNotContain(invoiceId, s.Driver.PageSource); // unarchive via list - s.Driver.FindElement(By.Id("SearchOptionsToggle")).Click(); - s.Driver.FindElement(By.Id("SearchOptionsIncludeArchived")).Click(); + s.Driver.FindElement(By.Id("StatusOptionsToggle")).Click(); + s.Driver.FindElement(By.Id("StatusOptionsIncludeArchived")).Click(); Assert.Contains(invoiceId, s.Driver.PageSource); s.Driver.FindElement(By.CssSelector($".selector[value=\"{invoiceId}\"]")).Click(); s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click(); diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 4078ee3cc3..2bf2a52dec 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -1073,34 +1073,44 @@ public async Task UpdateCustomer(string invoiceId, [FromBody] Upd public async Task ListInvoices(InvoicesModel? model = null) { model = this.ParseListQuery(model ?? new InvoicesModel()); - var fs = new SearchString(model.SearchTerm); + var timezoneOffset = model.TimezoneOffset ?? 0; + var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}"; + var fs = new SearchString(searchTerm, timezoneOffset); string? storeId = model.StoreId; var storeIds = new HashSet(); - if (fs.GetFilterArray("storeid") is string[] l) - { - foreach (var i in l) - storeIds.Add(i); - } if (storeId is not null) { storeIds.Add(storeId); - model.StoreId = storeId; } - model.StoreIds = storeIds.ToArray(); - - InvoiceQuery invoiceQuery = GetInvoiceQuery(model.SearchTerm, model.TimezoneOffset ?? 0); - invoiceQuery.StoreId = model.StoreIds; + if (fs.GetFilterArray("storeid") is { } l) + { + foreach (var i in l) + storeIds.Add(i); + } + model.Search = fs; + model.SearchText = fs.TextSearch; + + InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset); + invoiceQuery.StoreId = storeIds.ToArray(); invoiceQuery.Take = model.Count; invoiceQuery.Skip = model.Skip; invoiceQuery.IncludeRefunds = true; var list = await _InvoiceRepository.GetInvoices(invoiceQuery); - model.IncludeArchived = invoiceQuery.IncludeArchived; + // Apps + var apps = await _appService.GetAllApps(GetUserId(), false, storeId); + model.Apps = apps.Select(a => new InvoiceAppModel + { + Id = a.Id, + AppName = a.AppName, + AppType = a.AppType, + AppOrderId = AppService.GetAppOrderId(a.AppType, a.Id) + }).ToList(); foreach (var invoice in list) { var state = invoice.GetInvoiceState(); - model.Invoices.Add(new InvoiceModel() + model.Invoices.Add(new InvoiceModel { Status = state, ShowCheckout = invoice.Status == InvoiceStatusLegacy.New, @@ -1119,10 +1129,9 @@ public async Task ListInvoices(InvoicesModel? model = null) return View(model); } - private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffset = 0) + private InvoiceQuery GetInvoiceQuery(SearchString fs, int timezoneOffset = 0) { - var fs = new SearchString(searchTerm); - var invoiceQuery = new InvoiceQuery() + return new InvoiceQuery { TextSearch = fs.TextSearch, UserId = GetUserId(), @@ -1136,7 +1145,6 @@ private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffs StartDate = fs.GetFilterDate("startdate", timezoneOffset), EndDate = fs.GetFilterDate("enddate", timezoneOffset) }; - return invoiceQuery; } [HttpGet] @@ -1147,17 +1155,17 @@ public async Task Export(string format, string? storeId = null, s var model = new InvoiceExport(_CurrencyNameTable); var fs = new SearchString(searchTerm); var storeIds = new HashSet(); - if (fs.GetFilterArray("storeid") is string[] l) - { - foreach (var i in l) - storeIds.Add(i); - } if (storeId is not null) { storeIds.Add(storeId); } + if (fs.GetFilterArray("storeid") is { } l) + { + foreach (var i in l) + storeIds.Add(i); + } - InvoiceQuery invoiceQuery = GetInvoiceQuery(searchTerm, timezoneOffset); + InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset); invoiceQuery.StoreId = storeIds.ToArray(); invoiceQuery.Skip = 0; invoiceQuery.Take = int.MaxValue; diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index cc1c628786..ced80fcfed 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -58,6 +58,7 @@ public partial class UIInvoiceController : Controller private readonly InvoiceActivator _invoiceActivator; private readonly LinkGenerator _linkGenerator; private readonly IAuthorizationService _authorizationService; + private readonly AppService _appService; public WebhookSender WebhookNotificationManager { get; } @@ -81,6 +82,7 @@ public partial class UIInvoiceController : Controller UIWalletsController walletsController, InvoiceActivator invoiceActivator, LinkGenerator linkGenerator, + AppService appService, IAuthorizationService authorizationService) { _displayFormatter = displayFormatter; @@ -102,6 +104,7 @@ public partial class UIInvoiceController : Controller _invoiceActivator = invoiceActivator; _linkGenerator = linkGenerator; _authorizationService = authorizationService; + _appService = appService; } diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index d484bd144c..0a9022a716 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -78,7 +78,7 @@ public async Task GetPaymentRequests(string storeId, ListPaymentR model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel()); var store = GetCurrentStore(); - var includeArchived = new SearchString(model.SearchTerm).GetFilterBool("includearchived") == true; + var includeArchived = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0).GetFilterBool("includearchived") == true; var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery { UserId = GetUserId(), diff --git a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs index 3bafcbdbba..3fc04bc5f5 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs @@ -1,17 +1,18 @@ using System; using System.Collections.Generic; -using BTCPayServer.Client.Models; using BTCPayServer.Services.Invoices; namespace BTCPayServer.Models.InvoicingModels { public class InvoicesModel : BasePagingViewModel { - public List Invoices { get; set; } = new List(); + public List Invoices { get; set; } = new (); public override int CurrentPageCount => Invoices.Count; - public string[] StoreIds { get; set; } public string StoreId { get; set; } - public bool IncludeArchived { get; set; } + + public string SearchText { get; set; } + public SearchString Search { get; set; } + public List Apps { get; set; } } public class InvoiceModel @@ -34,4 +35,12 @@ public class InvoiceModel public InvoiceDetailsModel Details { get; set; } public bool HasRefund { get; set; } } + + public class InvoiceAppModel + { + public string Id { get; set; } + public string AppName { get; set; } + public string AppType { get; set; } + public string AppOrderId { get; set; } + } } diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs index 678330bdea..2639a04ed8 100644 --- a/BTCPayServer/SearchString.cs +++ b/BTCPayServer/SearchString.cs @@ -2,73 +2,129 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; +using ExchangeSharp; namespace BTCPayServer { public class SearchString { - readonly string _OriginalString; - public SearchString(string str) + private const char FilterSeparator = ','; + private const char ValueSeparator = ':'; + + private readonly string _originalString; + private readonly int _timezoneOffset; + + public SearchString(string str, int timezoneOffset = 0) { - str = str ?? string.Empty; + str ??= string.Empty; str = str.Trim(); - _OriginalString = str.Trim(); - TextSearch = _OriginalString; - var splitted = str.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + _originalString = str; + _timezoneOffset = timezoneOffset; + TextSearch = _originalString; + var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries); Filters = splitted - .Select(t => t.Split(new char[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries)) + .Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries)) .Where(kv => kv.Length == 2) - .Select(kv => new KeyValuePair(kv[0].ToLowerInvariant().Trim(), kv[1])) + .Select(kv => new KeyValuePair(UnifyKey(kv[0]), kv[1])) .ToMultiValueDictionary(o => o.Key, o => o.Value); - var val = splitted.FirstOrDefault(a => a?.IndexOf(':', StringComparison.OrdinalIgnoreCase) == -1); - if (val != null) - TextSearch = val.Trim(); - else - TextSearch = ""; + var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1); + TextSearch = val != null ? val.Trim() : string.Empty; } public string TextSearch { get; private set; } - public MultiValueDictionary Filters { get; private set; } + public MultiValueDictionary Filters { get; } public override string ToString() { - return _OriginalString; + return _originalString; + } + + public string Toggle(string key, string value) + { + key = UnifyKey(key); + var keyValue = $"{key}{ValueSeparator}{value}"; + var prependOnInsert = string.IsNullOrEmpty(ToString()) ? string.Empty : $"{ToString()}{FilterSeparator}"; + if (!ContainsFilter(key)) return Finalize($"{prependOnInsert}{keyValue}"); + + var boolFilter = GetFilterBool(key); + if (boolFilter != null) + { + return Finalize(ToString().Replace(keyValue, string.Empty)); + } + + var dateFilter = GetFilterDate(key, _timezoneOffset); + if (dateFilter != null) + { + var current = GetFilterArray(key).First(); + var oldValue = $"{key}{ValueSeparator}{current}"; + var newValue = string.IsNullOrEmpty(value) || current == value ? string.Empty : keyValue; + return Finalize(_originalString.Replace(oldValue, newValue)); + } + + var arrayFilter = GetFilterArray(key); + if (arrayFilter != null) + { + if (string.IsNullOrEmpty(value)) + { + return Finalize(arrayFilter.Aggregate(ToString(), (current, filter) => + current.Replace($"{key}{ValueSeparator}{filter}", string.Empty))); + } + return Finalize(arrayFilter.Contains(value) + ? ToString().Replace(keyValue, string.Empty) + : $"{prependOnInsert}{keyValue}" + ); + } + + return Finalize(ToString()); + } + + public string WithoutSearchText() + { + return string.IsNullOrEmpty(TextSearch) + ? Finalize(ToString()) + : Finalize(ToString()).Replace(TextSearch, string.Empty); } - internal string[] GetFilterArray(string key) + public string[] GetFilterArray(string key) { + key = UnifyKey(key); return Filters.ContainsKey(key) ? Filters[key].ToArray() : null; } - internal bool? GetFilterBool(string key) + public bool? GetFilterBool(string key) { + key = UnifyKey(key); if (!Filters.ContainsKey(key)) return null; - return bool.TryParse(Filters[key].First(), out var r) ? - r : (bool?)null; + return bool.TryParse(Filters[key].First(), out var r) ? r : null; } - internal DateTimeOffset? GetFilterDate(string key, int timezoneOffset) + public DateTimeOffset? GetFilterDate(string key, int timezoneOffset) { + key = UnifyKey(key); if (!Filters.ContainsKey(key)) return null; var val = Filters[key].First(); - - // handle special string values - if (val == "-24h") - return DateTimeOffset.UtcNow.AddHours(-24).AddMinutes(timezoneOffset); - else if (val == "-3d") - return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset); - else if (val == "-7d") - return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset); + switch (val) + { + // handle special string values + case "-24h": + case "-1d": + return DateTimeOffset.UtcNow.AddDays(-1).AddMinutes(timezoneOffset); + case "-3d": + return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset); + case "-7d": + return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset); + } // default parsing logic - var success = DateTimeOffset.TryParse(val, null as IFormatProvider, DateTimeStyles.AssumeUniversal, out var r); + var success = DateTimeOffset.TryParse(val, null, DateTimeStyles.AssumeUniversal, out var r); if (success) { r = r.AddMinutes(timezoneOffset); @@ -78,6 +134,20 @@ internal string[] GetFilterArray(string key) return null; } - internal bool ContainsFilter(string key) => Filters.ContainsKey(key); + public bool ContainsFilter(string key) + { + return Filters.ContainsKey(UnifyKey(key)); + } + + private string UnifyKey(string key) + { + return key.ToLowerInvariant().Trim(); + } + + private static string Finalize(string str) + { + var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator); + return string.IsNullOrEmpty(value) ? " " : value; + } } } diff --git a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml index aeca7945a9..e3a365eb08 100644 --- a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml +++ b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml @@ -5,9 +5,25 @@ @model InvoicesModel @{ ViewData.SetActivePage(InvoiceNavPages.Index, "Invoices"); - var storeIds = string.Join("", Model.StoreIds.Select(storeId => $",storeid:{storeId}")); - if (this.Context.GetRouteValue("storeId") is string) - storeIds = string.Empty; + + var statusFilterCount = CountArrayFilter("status") + CountArrayFilter("exceptionstatus") + (HasBooleanFilter("includearchived") ? 1 : 0) + (HasBooleanFilter("unusual") ? 1 : 0); + var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate"); + var appFilterCount = Model.Apps.Count(app => HasArrayFilter("orderid", app.AppOrderId)); +} + +@functions +{ + private int CountArrayFilter(string type) => + Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0; + + private bool HasArrayFilter(string type, string key = null) => + Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key)); + + 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"); } @section PageHeadContent @@ -16,6 +32,16 @@ .invoice-payments { padding-left: var(--btcpay-space-l); } + .dropdown > .btn { + min-width: 7rem; + padding-left: 1rem; + text-align: left; + } + @@media (max-width: 568px) { + #SearchText { + width: 100%; + } + } } @@ -59,7 +85,7 @@ var dtpStartDate = $("#dtpStartDate").val(); if (dtpStartDate !== null && dtpStartDate !== "") { - filterString = "startDate%3A" + dtpStartDate; + filterString = "startdate%3A" + dtpStartDate; } var dtpEndDate = $("#dtpEndDate").val(); @@ -67,7 +93,7 @@ if (filterString !== "") { filterString += ","; } - filterString += "endDate%3A" + dtpEndDate; + filterString += "enddate%3A" + dtpEndDate; } if (filterString !== "") { @@ -143,6 +169,14 @@

Invoices are documents issued by the seller to a buyer to collect payment.

An invoice must be paid within a defined time interval at a fixed exchange rate to protect the issuer from price fluctuations.

+

+ You can also apply filters to your search by searching for filtername:value. + Be sure to split your search parameters with comma. Supported filters are: +

+
    +
  • orderid:id for filtering a specific order
  • +
  • itemcode:code for filtering a specific type of item purchased through the pos or crowdfund apps
  • +
Learn More
- + + + } +