Invoice: Unify status display and functionality (#5360)

* Invoice: Unify status display and functionality

Consolidates the invoice status display and functionality (mark setted or invalid) across the dashboard, list and details pages.

* Test fix

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2023-10-11 16:12:45 +02:00 committed by GitHub
parent d44efce225
commit 2846c38ff5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 160 additions and 205 deletions

View file

@ -1,12 +1,12 @@
namespace BTCPayServer.Client.Models
namespace BTCPayServer.Client.Models;
public enum InvoiceExceptionStatus
{
public enum InvoiceExceptionStatus
{
None,
PaidLate,
PaidPartial,
Marked,
Invalid,
PaidOver
}
None,
PaidLate,
PaidPartial,
Marked,
Invalid,
PaidOver
}

View file

@ -556,24 +556,24 @@ namespace BTCPayServer.Tests
s.AddDerivationScheme();
s.GoToInvoices();
s.CreateInvoice();
s.Driver.FindElement(By.Id("markStatusDropdownMenuButton")).Click();
s.Driver.FindElements(By.ClassName("changeInvoiceState"))[0].Click();
s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge] .dropdown-toggle")).Click();
s.Driver.FindElements(By.CssSelector("[data-invoice-state-badge] .dropdown-menu button"))[0].Click();
TestUtils.Eventually(() => Assert.Contains("Invalid (marked)", s.Driver.PageSource));
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.Id("markStatusDropdownMenuButton")).Click();
s.Driver.FindElements(By.ClassName("changeInvoiceState"))[0].Click();
s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge] .dropdown-toggle")).Click();
s.Driver.FindElements(By.CssSelector("[data-invoice-state-badge] .dropdown-menu button"))[0].Click();
TestUtils.Eventually(() => Assert.Contains("Settled (marked)", s.Driver.PageSource));
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.Id("markStatusDropdownMenuButton")).Click();
s.Driver.FindElements(By.ClassName("changeInvoiceState"))[0].Click();
s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge] .dropdown-toggle")).Click();
s.Driver.FindElements(By.CssSelector("[data-invoice-state-badge] .dropdown-menu button"))[0].Click();
TestUtils.Eventually(() => Assert.Contains("Invalid (marked)", s.Driver.PageSource));
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.Id("markStatusDropdownMenuButton")).Click();
s.Driver.FindElements(By.ClassName("changeInvoiceState"))[0].Click();
s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge] .dropdown-toggle")).Click();
s.Driver.FindElements(By.CssSelector("[data-invoice-state-badge] .dropdown-menu button"))[0].Click();
TestUtils.Eventually(() => Assert.Contains("Settled (marked)", s.Driver.PageSource));
}

View file

@ -0,0 +1,67 @@
@using BTCPayServer.Services.Invoices
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Components.InvoiceStatus.InvoiceStatusViewModel
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@{
var state = Model.State.ToString();
var badgeClass = Model.State.Status.ToModernStatus().ToString().ToLower();
var canMark = !string.IsNullOrEmpty(Model.InvoiceId) && (Model.State.CanMarkComplete() || Model.State.CanMarkInvalid());
}
<div class="d-inline-flex align-items-center gap-2">
@if (Model.IsArchived)
{
<span class="badge bg-warning">archived</span>
}
<div class="badge badge-@badgeClass" data-invoice-state-badge="@Model.InvoiceId">
@if (canMark)
{
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@state
</span>
<div class="dropdown-menu">
@if (Model.State.CanMarkInvalid())
{
<button type="button" class="dropdown-item lh-base" data-invoice-id="@Model.InvoiceId" data-new-state="invalid">
Mark as invalid
</button>
}
@if (Model.State.CanMarkComplete())
{
<button type="button" class="dropdown-item lh-base" data-invoice-id="@Model.InvoiceId" data-new-state="settled">
Mark as settled
</button>
}
</div>
}
else
{
@state
}
</div>
@if (Model.Payments != null)
{
foreach (var paymentMethodId in Model.Payments.Select(payment => payment.GetPaymentMethodId()).Distinct())
{
var image = PaymentMethodHandlerDictionary[paymentMethodId]?.GetCryptoImage(paymentMethodId);
var badge = paymentMethodId.PaymentType.GetBadge();
if (!string.IsNullOrEmpty(image) || !string.IsNullOrEmpty(badge))
{
<span class="d-inline-flex align-items-center gap-1">
@if (!string.IsNullOrEmpty(image))
{
<img src="@Context.Request.GetRelativePathOrAbsolute(image)" alt="@paymentMethodId.PaymentType.ToString()" style="height:1.5em" />
}
@if (!string.IsNullOrEmpty(badge))
{
@badge
}
</span>
}
}
}
@if (Model.HasRefund)
{
<span class="badge bg-warning">Refund</span>
}
</div>

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.InvoiceStatus
{
public class InvoiceStatus : ViewComponent
{
public IViewComponentResult Invoke(InvoiceState state, List<PaymentEntity> payments, string invoiceId, bool isArchived = false, bool hasRefund = false)
{
var vm = new InvoiceStatusViewModel
{
State = state,
Payments = payments,
InvoiceId = invoiceId,
IsArchived = isArchived,
HasRefund = hasRefund
};
return View(vm);
}
}
}

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Components.InvoiceStatus
{
public class InvoiceStatusViewModel
{
public InvoiceState State { get; set; }
public List<PaymentEntity> Payments { get; set; }
public string InvoiceId { get; set; }
public bool IsArchived { get; set; }
public bool HasRefund { get; set; }
}
}

View file

@ -52,41 +52,8 @@
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<div class="d-flex align-items-center gap-2">
@if (invoice.Details.Archived)
{
<span class="badge bg-warning">archived</span>
}
<span class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
@invoice.Status.Status.ToModernStatus().ToString()
@if (invoice.Status.ExceptionStatus != InvoiceExceptionStatus.None)
{
@($"({invoice.Status.ExceptionStatus.ToString()})")
}
</span>
@foreach (var paymentMethodId in invoice.Details.Payments.Select(payment => payment.GetPaymentMethodId()).Distinct())
{
var image = PaymentMethodHandlerDictionary[paymentMethodId]?.GetCryptoImage(paymentMethodId);
var badge = paymentMethodId.PaymentType.GetBadge();
if (!string.IsNullOrEmpty(image) || !string.IsNullOrEmpty(badge))
{
<span class="d-inline-flex align-items-center gap-1">
@if (!string.IsNullOrEmpty(image))
{
<img src="@Context.Request.GetRelativePathOrAbsolute(image)" alt="@paymentMethodId.PaymentType.ToString()" style="height:1.5em" />
}
@if (!string.IsNullOrEmpty(badge))
{
@badge
}
</span>
}
}
@if (invoice.HasRefund)
{
<span class="badge bg-warning">Refund</span>
}
</div>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>

View file

@ -12,7 +12,6 @@ public class StoreRecentInvoiceViewModel
public string Currency { get; set; }
public InvoiceState Status { get; set; }
public DateTimeOffset Date { get; set; }
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}

View file

@ -150,15 +150,14 @@ namespace BTCPayServer.Controllers
Events = invoice.Events,
Metadata = metaData,
Archived = invoice.Archived,
HasRefund = invoice.Refunds.Any(),
CanRefund = invoiceState.CanRefund(),
Refunds = invoice.Refunds,
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
ShowReceipt = invoice.Status.ToModernStatus() == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId))
.Select(c => new Models.StoreViewModels.DeliveryViewModel(c))
.ToList(),
CanMarkInvalid = invoiceState.CanMarkInvalid(),
CanMarkSettled = invoiceState.CanMarkComplete(),
.ToList()
};
var details = InvoicePopulatePayments(invoice);
@ -1154,7 +1153,7 @@ namespace BTCPayServer.Controllers
CanMarkInvalid = state.CanMarkInvalid(),
CanMarkSettled = state.CanMarkComplete(),
Details = InvoicePopulatePayments(invoice),
HasRefund = invoice.Refunds.Any(data => !data.PullPaymentData.Archived)
HasRefund = invoice.Refunds.Any()
});
}
return View(model);

View file

@ -125,7 +125,7 @@ namespace BTCPayServer.Models.InvoicingModels
}
public InvoiceMetadata TypedMetadata { get; set; }
public DateTimeOffset MonitoringDate { get; internal set; }
public List<Data.InvoiceEventData> Events { get; internal set; }
public List<InvoiceEventData> Events { get; internal set; }
public string NotificationEmail { get; internal set; }
public Dictionary<string, object> Metadata { get; set; }
public Dictionary<string, object> ReceiptData { get; set; }
@ -134,12 +134,10 @@ namespace BTCPayServer.Models.InvoicingModels
public bool Archived { get; set; }
public bool CanRefund { get; set; }
public bool ShowCheckout { get; set; }
public bool CanMarkSettled { get; set; }
public bool CanMarkInvalid { get; set; }
public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid;
public List<RefundData> Refunds { get; set; }
public bool ShowReceipt { get; set; }
public bool Overpaid { get; set; }
public bool HasRefund { get; set; }
public bool StillDue { get; set; }
public bool HasRates { get; set; }
}

View file

@ -9,7 +9,6 @@ namespace BTCPayServer.Models.InvoicingModels
public List<InvoiceModel> Invoices { get; set; } = new ();
public override int CurrentPageCount => Invoices.Count;
public string StoreId { get; set; }
public string SearchText { get; set; }
public SearchString Search { get; set; }
public List<InvoiceAppModel> Apps { get; set; }
@ -22,16 +21,13 @@ namespace BTCPayServer.Models.InvoicingModels
public string OrderId { get; set; }
public string RedirectUrl { get; set; }
public string InvoiceId { get; set; }
public InvoiceState Status { get; set; }
public bool CanMarkSettled { get; set; }
public bool CanMarkInvalid { get; set; }
public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid;
public bool ShowCheckout { get; set; }
public string ExceptionStatus { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}

View file

@ -978,7 +978,15 @@ namespace BTCPayServer.Services.Invoices
}
public override string ToString()
{
return Status.ToModernStatus() + (ExceptionStatus == InvoiceExceptionStatus.None ? string.Empty : $" ({ToString(ExceptionStatus)})");
return Status.ToModernStatus() + ExceptionStatus switch
{
InvoiceExceptionStatus.PaidOver => " (paid over)",
InvoiceExceptionStatus.PaidLate => " (paid late)",
InvoiceExceptionStatus.PaidPartial => " (paid partial)",
InvoiceExceptionStatus.Marked => " (marked)",
InvoiceExceptionStatus.Invalid => " (invalid)",
_ => ""
};
}
}

View file

@ -34,30 +34,6 @@
@section PageFootContent {
<script>
const alertClasses = { "Settled (marked)": 'success', "Invalid (marked)": 'danger' }
function changeInvoiceState(invoiceId, newState) {
console.log(invoiceId, newState)
const toggleButton = $("#markStatusDropdownMenuButton");
toggleButton.attr("disabled", "disabled");
$.post(`${invoiceId}/changestate/${newState}`)
.done(({ statusString }) => {
const alertClass = alertClasses[statusString];
toggleButton.replaceWith(`<span class="fs-6 fw-normal badge bg-${alertClass}">${statusString} <span class="fa fa-check"></span></span>`);
})
.fail(function () {
toggleButton.removeAttr("disabled");
alert("Invoice state update failed");
});
}
delegate('click', '[data-change-invoice-status-button]', e => {
const button = e.target.closest('[data-change-invoice-status-button]')
const { id, status } = button.dataset
changeInvoiceState(id, status)
})
const handleRefundResponse = async response => {
const modalBody = document.querySelector('#RefundModal .modal-body')
if (response.ok && response.redirected) {
@ -282,32 +258,8 @@
<tr>
<th>State</th>
<td>
@if (Model.CanMarkStatus)
{
<div class="dropdown changeInvoiceStateToggle">
<button class="btn btn-secondary btn-sm dropdown-toggle py-1 px-2" type="button" id="markStatusDropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@Model.State
</button>
<div class="dropdown-menu" aria-labelledby="markStatusDropdownMenuButton">
@if (Model.CanMarkInvalid)
{
<button type="button" class="dropdown-item lh-base changeInvoiceState" data-id="@Model.Id" data-status="invalid" data-change-invoice-status-button>
Mark as invalid
</button>
}
@if (Model.CanMarkSettled)
{
<button type="button" class="dropdown-item lh-base changeInvoiceState" href="#" data-id="@Model.Id" data-status="settled" data-change-invoice-status-button>
Mark as settled
</button>
}
</div>
</div>
}
else
{
@Model.State
}
<vc:invoice-status invoice-id="@Model.Id" state="Model.State" payments="Model.Payments"
is-archived="Model.Archived" has-refund="Model.HasRefund" />
</td>
</tr>
<tr>

View file

@ -60,23 +60,6 @@
});
});
delegate('click', '.changeInvoiceState', e => {
const { invoiceId, newState } = e.target.dataset;
const pavpill = $("#pavpill_" + invoiceId);
const originalHtml = pavpill.html();
pavpill.html("<span class='fa fa-bitcoin fa-spin' style='margin-left:16px;'></span>");
$.post("invoices/" + invoiceId + "/changestate/" + newState)
.done(function (data) {
const statusHtml = "<span class='badge badge-" + newState + "'>" + data.statusString + " <span class='fa fa-check'></span></span>";
pavpill.replaceWith(statusHtml);
})
.fail(function (data) {
pavpill.html(originalHtml.replace("dropdown-menu show", "dropdown-menu"));
alert("Invoice state update failed");
});
})
delegate('click', '.showInvoice', e => {
e.preventDefault();
const { invoiceId } = e.target.dataset;
@ -386,66 +369,8 @@
</td>
<td class="text-break">@invoice.InvoiceId</td>
<td>
<div class="d-flex align-items-center gap-2">
@if (invoice.Details.Archived)
{
<span class="badge bg-warning">archived</span>
}
@if (invoice.CanMarkStatus)
{
<div id="pavpill_@invoice.InvoiceId" class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
<span class="dropdown-toggle changeInvoiceStateToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@invoice.Status.ToString()
</span>
<div class="dropdown-menu">
@if (invoice.CanMarkInvalid)
{
<button type="button" class="dropdown-item lh-base changeInvoiceState" data-invoice-id="@invoice.InvoiceId" data-new-state="invalid">
Mark as invalid
</button>
}
@if (invoice.CanMarkSettled)
{
<button type="button" class="dropdown-item lh-base changeInvoiceState" data-invoice-id="@invoice.InvoiceId" data-new-state="settled">
Mark as settled
</button>
}
</div>
</div>
}
else
{
<span class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
@invoice.Status.Status.ToModernStatus().ToString()
@if (invoice.Status.ExceptionStatus != InvoiceExceptionStatus.None)
{
@($"({invoice.Status.ExceptionStatus.ToString()})")
}
</span>
}
@foreach (var paymentMethodId in invoice.Details.Payments.Select(payment => payment.GetPaymentMethodId()).Distinct())
{
var image = PaymentMethodHandlerDictionary[paymentMethodId]?.GetCryptoImage(paymentMethodId);
var badge = paymentMethodId.PaymentType.GetBadge();
if (!string.IsNullOrEmpty(image) || !string.IsNullOrEmpty(badge))
{
<span class="d-inline-flex align-items-center gap-1">
@if (!string.IsNullOrEmpty(image))
{
<img src="@Context.Request.GetRelativePathOrAbsolute(image)" alt="@paymentMethodId.PaymentType.ToString()" style="height:1.5em" />
}
@if (!string.IsNullOrEmpty(badge))
{
@badge
}
</span>
}
}
@if (invoice.HasRefund)
{
<span class="badge bg-warning">Refund</span>
}
</div>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end text-nowrap">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>

View file

@ -263,17 +263,6 @@ h2 svg.icon.icon-info {
.card {
page-break-inside: avoid;
}
#markStatusDropdownMenuButton {
border: 0;
background: transparent;
padding: 0 !important;
color: inherit;
font-weight: var(--btcpay-font-weight-normal);
font-size: var(--btcpay-body-font-size);
}
#markStatusDropdownMenuButton::after {
content: none;
}
}
/* Richtext editor */

View file

@ -1,3 +1,5 @@
const baseUrl = Object.values(document.scripts).find(s => s.src.includes('/main/site.js')).src.split('/main/site.js').shift();
const flatpickrInstances = [];
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
@ -268,6 +270,23 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// Invoice Status
delegate('click', '[data-invoice-state-badge] [data-invoice-id][data-new-state]', async e => {
const $button = e.target
const $badge = $button.closest('[data-invoice-state-badge]')
const { invoiceId, newState } = $button.dataset
$badge.classList.add('pe-none'); // disable further interaction
const response = await fetch(`${baseUrl}/invoices/${invoiceId}/changestate/${newState}`, { method: 'POST' })
if (response.ok) {
const { statusString } = await response.json()
$badge.outerHTML = `<div class="badge badge-${newState}" data-invoice-state-badge="${invoiceId}">${statusString}</div>`
} else {
$badge.classList.remove('pe-none');
alert("Invoice state update failed");
}
})
// Time Format
delegate('click', '.switch-time-format', switchTimeFormat);