Checkout v2: Improve expired paid partial state (#4827)

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
d11n 2023-05-11 10:38:40 +02:00 committed by GitHub
parent 37f0498def
commit 25fb5c1293
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 100 additions and 19 deletions

View file

@ -16,6 +16,8 @@ namespace BTCPayServer.Client.Models
public string Website { get; set; }
public string SupportUrl { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);

View file

@ -1,13 +1,9 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Stores;
using NBitcoin;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;
@ -40,8 +36,10 @@ namespace BTCPayServer.Tests
// Configure store url
var storeUrl = "https://satoshisteaks.com/";
var supportUrl = "https://support.satoshisteaks.com/{InvoiceId}/";
s.GoToStore();
s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl);
s.Driver.FindElement(By.Id("StoreSupportUrl")).SendKeys(supportUrl);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
@ -140,8 +138,47 @@ namespace BTCPayServer.Tests
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text);
Assert.Contains("resubmit a payment", expiredSection.Text);
Assert.DoesNotContain("This invoice expired with partial payment", expiredSection.Text);
});
Assert.True(s.Driver.ElementDoesNotExist(By.Id("receipt-btn")));
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ContactLink")));
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// Expire paid partial
s.GoToHome();
invoiceId = s.CreateInvoice(2100, "EUR");
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
await Task.Delay(200);
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
var amountFraction = "0.00001";
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction));
await s.Server.ExplorerNode.GenerateAsync(1);
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
expirySeconds.Clear();
expirySeconds.SendKeys("3");
s.Driver.FindElement(By.Id("Expire")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("The invoice hasn't been paid in full.", paymentInfo.Text);
Assert.Contains("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text);
Assert.Contains("This invoice expired with partial payment", expiredSection.Text);
Assert.DoesNotContain("resubmit a payment", expiredSection.Text);
});
var contactLink = s.Driver.FindElement(By.Id("ContactLink"));
Assert.Equal("Contact us", contactLink.Text);
Assert.Matches(supportUrl.Replace("{InvoiceId}", invoiceId), contactLink.GetAttribute("href"));
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// Test payment
@ -166,7 +203,7 @@ namespace BTCPayServer.Tests
// Pay partial amount
await Task.Delay(200);
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
var amountFraction = "0.00001";
amountFraction = "0.00001";
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction));
await s.Server.ExplorerNode.GenerateAsync(1);
@ -210,7 +247,8 @@ namespace BTCPayServer.Tests
Assert.Contains("Invoice Paid", settledSection.Text);
});
s.Driver.FindElement(By.Id("confetti"));
s.Driver.FindElement(By.Id("receipt-btn"));
s.Driver.FindElement(By.Id("ReceiptLink"));
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ContactLink")));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// BIP21
@ -358,6 +396,7 @@ namespace BTCPayServer.Tests
s.GoToHome();
s.GoToLightningSettings();
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), false);
s.Driver.ScrollTo(By.Id("save"));
s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);

View file

@ -600,7 +600,7 @@ namespace BTCPayServer.Tests
TestUtils.Eventually(() =>
{
s.Driver.Navigate().Refresh();
s.Driver.FindElement(By.Id("receipt-btn")).Click();
s.Driver.FindElement(By.Id("ReceiptLink")).Click();
});
TestUtils.Eventually(() =>
{
@ -612,7 +612,7 @@ namespace BTCPayServer.Tests
await s.Server.PayTester.InvoiceRepository.MarkInvoiceStatus(i, InvoiceStatus.Settled);
TestUtils.Eventually(() => s.Driver.FindElement(By.Id("receipt-btn")).Click());
TestUtils.Eventually(() => s.Driver.FindElement(By.Id("ReceiptLink")).Click());
TestUtils.Eventually(() =>
{
s.Driver.Navigate().Refresh();

View file

@ -115,11 +115,12 @@ namespace BTCPayServer.Controllers.Greenfield
internal static Client.Models.StoreData FromModel(Data.StoreData data)
{
var storeBlob = data.GetStoreBlob();
return new Client.Models.StoreData()
return new Client.Models.StoreData
{
Id = data.Id,
Name = data.StoreName,
Website = data.StoreWebsite,
SupportUrl = storeBlob.StoreSupportUrl,
SpeedPolicy = data.SpeedPolicy,
DefaultPaymentMethod = data.GetDefaultPaymentId()?.ToStringNormalized(),
//blob
@ -186,6 +187,7 @@ namespace BTCPayServer.Controllers.Greenfield
blob.ShowRecommendedFee = restModel.ShowRecommendedFee;
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget;
blob.DefaultLang = restModel.DefaultLang;
blob.StoreSupportUrl = restModel.SupportUrl;
blob.MonitoringExpiration = restModel.MonitoringExpiration;
blob.InvoiceExpiration = restModel.InvoiceExpiration;
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer;

View file

@ -857,13 +857,20 @@ namespace BTCPayServer.Controllers
isAltcoinsBuild = true;
#endif
var orderId = invoice.Metadata.OrderId;
var supportUrl = !string.IsNullOrEmpty(storeBlob.StoreSupportUrl)
? storeBlob.StoreSupportUrl
.Replace("{OrderId}", string.IsNullOrEmpty(orderId) ? string.Empty : Uri.EscapeDataString(orderId))
.Replace("{InvoiceId}", Uri.EscapeDataString(invoice.Id))
: null;
var model = new PaymentModel
{
Activated = paymentMethodDetails.Activated,
CryptoCode = network.CryptoCode,
RootPath = Request.PathBase.Value.WithTrailingSlash(),
OrderId = invoice.Metadata.OrderId,
InvoiceId = invoice.Id,
OrderId = orderId,
InvoiceId = invoiceId,
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
ShowPayInWalletButton = storeBlob.ShowPayInWalletButton,
ShowStoreHeader = storeBlob.ShowStoreHeader,
@ -895,6 +902,7 @@ namespace BTCPayServer.Controllers
ReceiptLink = receiptUrl,
RedirectAutomatically = invoice.RedirectAutomatically,
StoreName = store.StoreName,
StoreSupportUrl = supportUrl,
TxCount = accounting.TxRequired,
TxCountForFee = storeBlob.NetworkFeeMode switch
{

View file

@ -154,6 +154,7 @@ namespace BTCPayServer.Controllers
entity.Type = InvoiceType.TopUp;
}
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);

View file

@ -611,6 +611,7 @@ namespace BTCPayServer.Controllers
Id = store.Id,
StoreName = store.StoreName,
StoreWebsite = store.StoreWebsite,
StoreSupportUrl = storeBlob.StoreSupportUrl,
LogoFileId = storeBlob.LogoFileId,
CssFileId = storeBlob.CssFileId,
BrandColor = storeBlob.BrandColor,
@ -646,6 +647,7 @@ namespace BTCPayServer.Controllers
}
var blob = CurrentStore.GetStoreBlob();
blob.StoreSupportUrl = model.StoreSupportUrl;
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
blob.NetworkFeeMode = model.NetworkFeeMode;
blob.PaymentTolerance = model.PaymentTolerance;

View file

@ -66,6 +66,8 @@ namespace BTCPayServer.Data
}
}
public string StoreSupportUrl { get; set; }
CurrencyPair[] _DefaultCurrencyPairs;
[JsonProperty("defaultCurrencyPairs", ItemConverterType = typeof(CurrencyPairJsonConverter))]
public CurrencyPair[] DefaultCurrencyPairs

View file

@ -61,7 +61,7 @@ namespace BTCPayServer.Models.InvoicingModels
public int TxCount { get; set; }
public int TxCountForFee { get; set; }
public string BtcPaid { get; set; }
public string StoreEmail { get; set; }
public string StoreSupportUrl { get; set; }
public string OrderId { get; set; }
public decimal NetworkFee { get; set; }

View file

@ -22,6 +22,10 @@ namespace BTCPayServer.Models.StoreViewModels
[MaxLength(500)]
public string StoreWebsite { get; set; }
[Display(Name = "Support URL")]
[MaxLength(500)]
public string StoreSupportUrl { get; set; }
[Display(Name = "Brand Color")]
public string BrandColor { get; set; }

View file

@ -408,6 +408,8 @@ namespace BTCPayServer.Services.Invoices
// public bool Refundable { get; set; }
public bool? RequiresRefundEmail { get; set; } = null;
public string RefundMail { get; set; }
public string StoreSupportUrl { get; set; }
[JsonProperty("redirectURL")]
public string RedirectURLTemplate { get; set; }

View file

@ -163,7 +163,7 @@
</div>
</div>
<div class="buttons">
<a v-if="srvModel.receiptLink" class="btn btn-primary rounded-pill w-100" :href="srvModel.receiptLink" :target="isModal ? '_top' : null" v-t="'view_receipt'" id="receipt-btn"></a>
<a v-if="srvModel.receiptLink" class="btn btn-primary rounded-pill w-100" :href="srvModel.receiptLink" :target="isModal ? '_top' : null" v-t="'view_receipt'" id="ReceiptLink"></a>
<a v-if="storeLink" class="btn btn-secondary rounded-pill w-100" :href="storeLink" :target="isModal ? '_top' : null" v-html="$t('return_to_store', { storeName: srvModel.storeName })" id="StoreLink"></a>
<button v-else-if="isModal" class="btn btn-secondary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
</div>
@ -198,9 +198,10 @@
<span class="fw-semibold" v-t="'view_details'"></span>
<vc:icon symbol="caret-down" />
</button>
<p class="text-center mt-3" v-html="replaceNewlines($t('invoice_expired_body', { storeName: srvModel.storeName, minutes: @Model.MaxTimeMinutes }))"></p>
<p class="text-center mt-3" v-html="replaceNewlines($t(isPaidPartial ? 'invoice_paidpartial_body' : 'invoice_expired_body', { storeName: srvModel.storeName, minutes: srvModel.maxTimeMinutes }))"></p>
</div>
<div class="buttons">
<a v-if="isPaidPartial && srvModel.storeSupportUrl" class="btn btn-primary rounded-pill w-100" :href="srvModel.storeSupportUrl" v-t="'contact_us'" id="ContactLink"></a>
<a v-if="storeLink" class="btn btn-primary rounded-pill w-100" :href="storeLink" :target="isModal ? '_top' : null" v-html="$t('return_to_store', { storeName: srvModel.storeName })" id="StoreLink"></a>
<button v-else-if="isModal" class="btn btn-primary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
</div>

View file

@ -32,6 +32,14 @@
<input asp-for="StoreWebsite" class="form-control" />
<span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="StoreSupportUrl" class="form-label"></label>
<input asp-for="StoreSupportUrl" class="form-control" />
<span asp-validation-for="StoreSupportUrl" class="text-danger"></span>
<div class="form-text">
For support requests, can contain the placeholders <code>{OrderId}</code> and <code>{InvoiceId}</code>. Can be any valid URI, such as a website, email, and nostr.",
</div>
</div>
<h3 class="mt-5 mb-3">Branding</h3>
<div class="form-group">

View file

@ -132,6 +132,9 @@ function initApp() {
isActive () {
return STATUS_PAYABLE.includes(this.srvModel.status);
},
isPaidPartial () {
return this.btcPaid > 0 && this.btcDue > 0;
},
showInfo () {
return this.showTimer || this.showPaymentDueInfo;
},
@ -139,7 +142,7 @@ function initApp() {
return this.isActive && this.remainingSeconds < this.srvModel.displayExpirationTimer;
},
showPaymentDueInfo () {
return this.btcPaid > 0 && this.btcDue > 0;
return this.isPaidPartial;
},
showRecommendedFee () {
return this.isActive && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
@ -320,7 +323,6 @@ function initApp() {
const { status } = data;
window.parent.postMessage({ invoiceId, status }, '*');
}
const newEnd = new Date();
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
this.endDate = newEnd;

View file

@ -34,8 +34,10 @@
"invoice_paid": "Invoice Paid",
"invoice_expired": "Invoice Expired",
"invoice_expired_body": "An invoice is only valid for {{minutes}} minutes.\n\nReturn to {{storeName}} if you would like to resubmit a payment.",
"invoice_paidpartial_body": "An invoice is only valid for {{minutes}} minutes.\n\nThis invoice expired with partial payment. Please contact us, so that we can fulfill your order.",
"view_receipt": "View receipt",
"return_to_store": "Return to {{storeName}}",
"contact_us": "Contact us",
"copy": "Copy",
"copy_confirm": "Copied",
"powered_by": "Powered by",

View file

@ -323,6 +323,12 @@
"description": "The absolute url of the store",
"format": "url"
},
"supportUrl": {
"type": "string",
"nullable": true,
"description": "The support URI of the store, can contain the placeholders `{OrderId}` and `{InvoiceId}`. Can be any valid URI, such as a website, email, and nostr.",
"format": "uri"
},
"defaultCurrency": {
"type": "string",
"description": "The default currency of the store",