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
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 Website { get; set; }
public string SupportUrl { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15); public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);

View File

@@ -1,13 +1,9 @@
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using NBitcoin; using NBitcoin;
using OpenQA.Selenium; using OpenQA.Selenium;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI; using OpenQA.Selenium.Support.UI;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -40,8 +36,10 @@ namespace BTCPayServer.Tests
// Configure store url // Configure store url
var storeUrl = "https://satoshisteaks.com/"; var storeUrl = "https://satoshisteaks.com/";
var supportUrl = "https://support.satoshisteaks.com/{InvoiceId}/";
s.GoToStore(); s.GoToStore();
s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl); s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl);
s.Driver.FindElement(By.Id("StoreSupportUrl")).SendKeys(supportUrl);
s.Driver.FindElement(By.Id("Save")).Click(); s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text); Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
@@ -140,8 +138,47 @@ namespace BTCPayServer.Tests
var expiredSection = s.Driver.FindElement(By.Id("unpaid")); var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
Assert.True(expiredSection.Displayed); Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text); 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")); Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// Test payment // Test payment
@@ -166,7 +203,7 @@ namespace BTCPayServer.Tests
// Pay partial amount // Pay partial amount
await Task.Delay(200); await Task.Delay(200);
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); 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), await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction)); Money.Parse(amountFraction));
await s.Server.ExplorerNode.GenerateAsync(1); await s.Server.ExplorerNode.GenerateAsync(1);
@@ -210,7 +247,8 @@ namespace BTCPayServer.Tests
Assert.Contains("Invoice Paid", settledSection.Text); Assert.Contains("Invoice Paid", settledSection.Text);
}); });
s.Driver.FindElement(By.Id("confetti")); 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")); Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// BIP21 // BIP21
@@ -358,6 +396,7 @@ namespace BTCPayServer.Tests
s.GoToHome(); s.GoToHome();
s.GoToLightningSettings(); s.GoToLightningSettings();
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), false); s.Driver.SetCheckbox(By.Id("LNURLEnabled"), false);
s.Driver.ScrollTo(By.Id("save"));
s.Driver.FindElement(By.Id("save")).Click(); s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text); Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);

View File

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

View File

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

View File

@@ -854,16 +854,23 @@ namespace BTCPayServer.Controllers
var isAltcoinsBuild = false; var isAltcoinsBuild = false;
#if ALTCOINS #if ALTCOINS
isAltcoinsBuild = true; isAltcoinsBuild = true;
#endif #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 var model = new PaymentModel
{ {
Activated = paymentMethodDetails.Activated, Activated = paymentMethodDetails.Activated,
CryptoCode = network.CryptoCode, CryptoCode = network.CryptoCode,
RootPath = Request.PathBase.Value.WithTrailingSlash(), RootPath = Request.PathBase.Value.WithTrailingSlash(),
OrderId = invoice.Metadata.OrderId, OrderId = orderId,
InvoiceId = invoice.Id, InvoiceId = invoiceId,
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en", DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
ShowPayInWalletButton = storeBlob.ShowPayInWalletButton, ShowPayInWalletButton = storeBlob.ShowPayInWalletButton,
ShowStoreHeader = storeBlob.ShowStoreHeader, ShowStoreHeader = storeBlob.ShowStoreHeader,
@@ -895,6 +902,7 @@ namespace BTCPayServer.Controllers
ReceiptLink = receiptUrl, ReceiptLink = receiptUrl,
RedirectAutomatically = invoice.RedirectAutomatically, RedirectAutomatically = invoice.RedirectAutomatically,
StoreName = store.StoreName, StoreName = store.StoreName,
StoreSupportUrl = supportUrl,
TxCount = accounting.TxRequired, TxCount = accounting.TxRequired,
TxCountForFee = storeBlob.NetworkFeeMode switch TxCountForFee = storeBlob.NetworkFeeMode switch
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -163,7 +163,7 @@
</div> </div>
</div> </div>
<div class="buttons"> <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> <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> <button v-else-if="isModal" class="btn btn-secondary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
</div> </div>
@@ -198,9 +198,10 @@
<span class="fw-semibold" v-t="'view_details'"></span> <span class="fw-semibold" v-t="'view_details'"></span>
<vc:icon symbol="caret-down" /> <vc:icon symbol="caret-down" />
</button> </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>
<div class="buttons"> <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> <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> <button v-else-if="isModal" class="btn btn-primary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
</div> </div>

View File

@@ -32,6 +32,14 @@
<input asp-for="StoreWebsite" class="form-control" /> <input asp-for="StoreWebsite" class="form-control" />
<span asp-validation-for="StoreWebsite" class="text-danger"></span> <span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div> </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> <h3 class="mt-5 mb-3">Branding</h3>
<div class="form-group"> <div class="form-group">

View File

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

View File

@@ -34,10 +34,12 @@
"invoice_paid": "Invoice Paid", "invoice_paid": "Invoice Paid",
"invoice_expired": "Invoice Expired", "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_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", "view_receipt": "View receipt",
"return_to_store": "Return to {{storeName}}", "return_to_store": "Return to {{storeName}}",
"contact_us": "Contact us",
"copy": "Copy", "copy": "Copy",
"copy_confirm": "Copied", "copy_confirm": "Copied",
"powered_by": "Powered by", "powered_by": "Powered by",
"conversion_body": "This service is provided by 3rd party. Please keep in mind that we have no control over how providers will forward your funds. Invoice will only be marked paid once funds are received on the {{cryptoCode}} blockchain." "conversion_body": "This service is provided by 3rd party. Please keep in mind that we have no control over how providers will forward your funds. Invoice will only be marked paid once funds are received on the {{cryptoCode}} blockchain."
} }

View File

@@ -323,6 +323,12 @@
"description": "The absolute url of the store", "description": "The absolute url of the store",
"format": "url" "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": { "defaultCurrency": {
"type": "string", "type": "string",
"description": "The default currency of the store", "description": "The default currency of the store",