mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-10 09:19:24 +01:00
Checkout v2: Improve expired paid partial state (#4827)
Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
parent
37f0498def
commit
25fb5c1293
16 changed files with 100 additions and 19 deletions
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -854,16 +854,23 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
var isAltcoinsBuild = false;
|
||||
#if ALTCOINS
|
||||
isAltcoinsBuild = true;
|
||||
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
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -65,6 +65,8 @@ namespace BTCPayServer.Data
|
|||
_DefaultCurrency = _DefaultCurrency.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public string StoreSupportUrl { get; set; }
|
||||
|
||||
CurrencyPair[] _DefaultCurrencyPairs;
|
||||
[JsonProperty("defaultCurrencyPairs", ItemConverterType = typeof(CurrencyPairJsonConverter))]
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -34,10 +34,12 @@
|
|||
"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",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue