Checkout v2: Payment processing state (#4778)

This commit is contained in:
d11n 2023-03-27 12:12:11 +02:00 committed by GitHub
parent de9ac9fd43
commit 45141d1391
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 187 additions and 72 deletions

View file

@ -140,7 +140,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("expired"));
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text);
});
@ -181,12 +181,27 @@ namespace BTCPayServer.Tests
{
Assert.Contains("Created transaction",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
s.Server.ExplorerNode.Generate(1);
s.Server.ExplorerNode.Generate(2);
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);
});
// Pay full amount
var amountDue = s.Driver.FindElement(By.Id("AmountDue")).GetAttribute("data-amount-due");
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountDue);
s.Driver.FindElement(By.Id("FakePay")).Click();
// Processing
TestUtils.Eventually(() =>
{
var processingSection = s.Driver.WaitForElement(By.Id("processing"));
Assert.True(processingSection.Displayed);
Assert.Contains("Payment Sent", processingSection.Text);
Assert.Contains("Your payment has been received and is now processing", processingSection.Text);
Assert.True(s.Driver.ElementDoesNotExist(By.Id("confetti")));
});
// Mine
s.Driver.FindElement(By.Id("Mine")).Click();
TestUtils.Eventually(() =>
@ -194,17 +209,13 @@ namespace BTCPayServer.Tests
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
});
// Pay full amount
var amountDue = s.Driver.FindElement(By.Id("AmountDue")).GetAttribute("data-amount-due");
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountDue);
s.Driver.FindElement(By.Id("FakePay")).Click();
// Settled
TestUtils.Eventually(() =>
{
s.Server.ExplorerNode.Generate(1);
var paidSection = s.Driver.WaitForElement(By.Id("paid"));
Assert.True(paidSection.Displayed);
Assert.Contains("Invoice Paid", paidSection.Text);
var settledSection = s.Driver.WaitForElement(By.Id("settled"));
Assert.True(settledSection.Displayed);
Assert.Contains("Invoice Paid", settledSection.Text);
});
s.Driver.FindElement(By.Id("confetti"));
s.Driver.FindElement(By.Id("ReceiptLink"));

View file

@ -80,15 +80,13 @@ namespace BTCPayServer.Controllers
}
return UnprocessableEntity(new
{
ErrorMessage = response.ErrorDetail,
AmountRemaining = invoice.Price
ErrorMessage = response.ErrorDetail
});
default:
return UnprocessableEntity(new
{
ErrorMessage = $"Payment method {paymentMethodId} is not supported",
AmountRemaining = invoice.Price
ErrorMessage = $"Payment method {paymentMethodId} is not supported"
});
}
@ -97,8 +95,7 @@ namespace BTCPayServer.Controllers
{
return BadRequest(new
{
ErrorMessage = e.Message,
AmountRemaining = invoice.Price
ErrorMessage = e.Message
});
}
}

View file

@ -829,6 +829,15 @@ namespace BTCPayServer.Controllers
NetworkFeeMode.Never => 0,
_ => throw new NotImplementedException()
},
RequiredConfirmations = invoice.SpeedPolicy switch
{
SpeedPolicy.HighSpeed => 0,
SpeedPolicy.MediumSpeed => 1,
SpeedPolicy.LowMediumSpeed => 2,
SpeedPolicy.LowSpeed => 6,
_ => null
},
ReceivedConfirmations = invoice.GetAllBitcoinPaymentData(false).FirstOrDefault()?.ConfirmationCount,
#pragma warning disable CS0618 // Type or member is obsolete
Status = invoice.StatusString,
#pragma warning restore CS0618 // Type or member is obsolete

View file

@ -77,5 +77,7 @@ namespace BTCPayServer.Models.InvoicingModels
public string ReceiptLink { get; set; }
public bool AltcoinsBuild { get; set; }
public CheckoutType CheckoutType { get; set; }
public int? RequiredConfirmations { get; set; }
public long? ReceivedConfirmations { get; set; }
}
}

View file

@ -1,22 +1,25 @@
@model PaymentModel
<style>
#checkout-cheating form + form { margin-top: var(--btcpay-space-l); }
</style>
<main id="checkout-cheating" class="shadow-lg" v-cloak v-if="display">
<section>
<p id="CheatSuccessMessage" class="alert alert-success text-break" v-if="successMessage" v-text="successMessage"></p>
<p id="CheatErrorMessage" class="alert alert-danger text-break" v-if="errorMessage" v-text="errorMessage"></p>
<form id="test-payment" :action="`/i/${invoiceId}/test-payment`" method="post" v-on:submit.prevent="handleFormSubmit($event, 'paying')" v-if="displayPayment">
<input name="CryptoCode" type="hidden" value="@Model.CryptoCode">
<input name="CryptoCode" type="hidden" :value="cryptoCode">
<input name="PaymentMethodId" type="hidden" :value="paymentMethodId">
<label for="FakePayAmount" class="control-label form-label">Fake a @Model.CryptoCode payment for testing</label>
<label for="FakePayAmount" class="control-label form-label">Fake a {{cryptoCode}} payment for testing</label>
<div class="d-flex gap-2 mb-2">
<div class="input-group">
<input id="FakePayAmount" name="Amount" type="number" step="0.00000001" min="0" class="form-control" placeholder="Amount" v-model="amountRemaining" :disabled="paying || paymentMethodId === 'BTC_LightningLike'"/>
<div id="test-payment-crypto-code" class="input-group-addon input-group-text">@Model.CryptoCode</div>
<input id="FakePayAmount" name="Amount" type="number" :step="isSats ? '1' : '0.00000001'" min="0" class="form-control" placeholder="Amount" v-model="amountRemaining" :disabled="paying || paymentMethodId === 'BTC_LightningLike'"/>
<div id="test-payment-crypto-code" class="input-group-addon input-group-text" v-text="cryptoCode"></div>
</div>
<button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="paying" id="FakePay">Pay</button>
</div>
</form>
<form id="mine-block" :action="`/i/${invoiceId}/mine-blocks`" method="post" class="mt-4" v-on:submit.prevent="handleFormSubmit($event, 'mining')" v-if="displayMine">
<form id="mine-block" :action="`/i/${invoiceId}/mine-blocks`" method="post" v-on:submit.prevent="handleFormSubmit($event, 'mining')" v-if="displayMine">
<label for="BlockCount" class="control-label form-label">Mine to test processing and settlement</label>
<div class="d-flex gap-2">
<div class="input-group">
@ -26,7 +29,7 @@
<button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="mining" id="Mine">Mine</button>
</div>
</form>
<form id="expire-invoice" :action="`/i/${invoiceId}/expire`" method="post" class="mt-4" v-on:submit.prevent="handleFormSubmit($event, 'expiring')" v-if="displayExpire">
<form id="expire-invoice" :action="`/i/${invoiceId}/expire`" method="post" v-on:submit.prevent="handleFormSubmit($event, 'expiring')" v-if="displayExpire">
<label for="ExpirySeconds" class="control-label form-label">Expire invoice in …</label>
<div class="d-flex gap-2">
<div class="input-group">
@ -49,27 +52,32 @@
paying: false,
mining: false,
expiring: false,
amountRemaining: parseFloat(this.btcDue)
amountRemaining: this.btcDue
}
},
props: {
invoiceId: String,
paymentMethodId: String,
cryptoCode: String,
btcDue: Number,
isPaid: Boolean
isProcessing: Boolean,
isSettled: Boolean
},
computed: {
display() {
return this.successMessage || this.errorMessage || this.displayPayment || this.displayMine || this.displayExpire;
},
displayPayment () {
return !this.isPaid;
return !this.isSettled && !this.isProcessing;
},
displayExpire () {
return !this.isPaid;
return !this.isSettled && !this.isProcessing;
},
displayMine () {
return this.paymentMethodId === 'BTC';
},
isSats () {
return this.cryptoCode === 'sats';
}
},
methods: {

View file

@ -76,7 +76,14 @@
<vc:icon symbol="caret-down" />
</button>
<div id="PaymentDetails" class="payment-details" v-collapsible="displayPaymentDetails">
<payment-details :srv-model="srvModel" :is-active="isActive" class="pb-4"></payment-details>
<payment-details
:srv-model="srvModel"
:is-active="isActive"
:order-amount="orderAmount"
:btc-paid="btcPaid"
:btc-due="btcDue"
:show-recommended-fee="showRecommendedFee"
class="pb-4" />
</div>
@if (displayedPaymentMethods.Count > 1 || hasPaymentPlugins)
{
@ -99,11 +106,45 @@
<component v-if="paymentMethodComponent" :is="paymentMethodComponent" :model="srvModel" />
</section>
<section id="result" v-else>
<div id="paid" v-if="isPaid">
<div v-if="isProcessing" id="processing" key="processing">
<div class="top">
<span class="icn">
<vc:icon symbol="payment-sent" />
</span>
<h4 v-t="'payment_sent'"></h4>
<div id="PaymentDetails" class="payment-details">
<dl class="mb-0">
<div>
<dt v-t="'invoice_id'"></dt>
<dd v-text="srvModel.invoiceId" :data-clipboard="srvModel.invoiceId" :data-clipboard-confirm="$t('copy_confirm')"></dd>
</div>
<div v-if="srvModel.orderId">
<dt v-t="'order_id'"></dt>
<dd v-text="srvModel.orderId" :data-clipboard="srvModel.orderId" :data-clipboard-confirm="$t('copy_confirm')"></dd>
</div>
</dl>
<payment-details
:srv-model="srvModel"
:is-active="isActive"
:order-amount="orderAmount"
:btc-paid="btcPaid"
:btc-due="btcDue"
:show-recommended-fee="showRecommendedFee"
v-collapsible="displayPaymentDetails" />
</div>
<button class="d-flex align-items-center gap-1 btn btn-link payment-details-button" type="button" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<span class="fw-semibold" v-t="'view_details'"></span>
<vc:icon symbol="caret-down" />
</button>
<p class="text-center mt-3" v-t="'payment_sent_body'"></p>
<p class="text-center" v-if="srvModel.receivedConfirmations !== null && srvModel.requiredConfirmations != null" v-t="{ path: 'payment_sent_confirmations', args: { cryptoCode: realCryptoCode, receivedConfirmations: srvModel.receivedConfirmations, requiredConfirmations: srvModel.requiredConfirmations } }"></p>
</div>
</div>
<div v-if="isSettled" id="settled" key="settled">
<div class="top">
<span class="icn">
<div id="confetti" v-if="srvModel.celebratePayment" v-on:click="celebratePayment(5000)"></div>
<vc:icon symbol="payment-complete"/>
<vc:icon symbol="payment-complete" />
</span>
<h4 v-t="'invoice_paid'"></h4>
<div id="PaymentDetails" class="payment-details">
@ -117,16 +158,23 @@
<dd v-text="srvModel.orderId" :data-clipboard="srvModel.orderId" data-clipboard-hover="start"></dd>
</div>
</dl>
<payment-details :srv-model="srvModel" :is-active="isActive" class="mb-5"></payment-details>
<payment-details
:srv-model="srvModel"
:is-active="isActive"
:order-amount="orderAmount"
:btc-paid="btcPaid"
:btc-due="btcDue"
:show-recommended-fee="showRecommendedFee"
class="mb-5" />
</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="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>
</div>
</div>
<div id="expired" v-if="isUnpayable">
<div v-if="isInvalid" id="unpaid" key="unpaid">
<div class="top">
<span class="icn">
<vc:icon symbol="invoice-expired" />
@ -143,7 +191,14 @@
<dd v-text="srvModel.orderId" :data-clipboard="srvModel.orderId" data-clipboard-hover="start"></dd>
</div>
</dl>
<payment-details :srv-model="srvModel" :is-active="isActive" v-collapsible="displayPaymentDetails"></payment-details>
<payment-details
:srv-model="srvModel"
:is-active="isActive"
:order-amount="orderAmount"
:btc-paid="btcPaid"
:btc-due="btcDue"
:show-recommended-fee="showRecommendedFee"
v-collapsible="displayPaymentDetails" />
</div>
<button class="d-flex align-items-center gap-1 btn btn-link payment-details-button" type="button" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<span class="fw-semibold" v-t="'view_details'"></span>
@ -160,7 +215,7 @@
</main>
@if (Env.CheatMode)
{
<checkout-cheating invoice-id="@Model.InvoiceId" :btc-due="srvModel.btcDue" :is-paid="isPaid" :payment-method-id="pmId"></checkout-cheating>
<checkout-cheating invoice-id="@Model.InvoiceId" :btc-due="btcDue" :is-settled="isSettled" :is-processing="isProcessing" :payment-method-id="pmId" :crypto-code="srvModel.cryptoCode"></checkout-cheating>
}
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">

View file

@ -148,10 +148,11 @@ section dl > div dd {
width: 1.5rem;
height: 1.5rem;
}
#result #paid .top .icn .icon {
#result #settled .top .icn .icon,
#result #processing .top .icn .icon {
color: var(--btcpay-primary);
}
#result #expired .top .icn .icon {
#result #unpaid .top .icn .icon {
color: var(--btcpay-body-text-muted);
}
#DefaultLang {

View file

@ -36,8 +36,11 @@ Vue.directive('collapsible', {
}
});
const STATUS_PAID = ['complete', 'confirmed', 'paid'];
const STATUS_UNPAYABLE = ['expired', 'invalid'];
// These are the legacy states, see InvoiceEntity
const STATUS_PAYABLE = ['new'];
const STATUS_PAID = ['paid'];
const STATUS_SETTLED = ['complete', 'confirmed'];
const STATUS_INVALID = ['expired', 'invalid'];
const urlParams = new URLSearchParams(window.location.search);
function computeStartingLanguage() {
@ -82,21 +85,11 @@ const PaymentDetails = {
template: '#payment-details',
props: {
srvModel: Object,
isActive: Boolean
},
computed: {
orderAmount () {
return parseFloat(this.srvModel.orderAmount);
},
btcDue () {
return parseFloat(this.srvModel.btcDue);
},
btcPaid () {
return parseFloat(this.srvModel.btcPaid);
},
showRecommendedFee () {
return this.isActive && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
},
isActive: Boolean,
showRecommendedFee: Boolean,
orderAmount: Number,
btcPaid: Number,
btcDue: Number
}
}
@ -118,18 +111,22 @@ function initApp() {
emailAddressInputInvalid: false,
paymentMethodId: null,
endData: null,
isModal: srvModel.isModal
isModal: srvModel.isModal,
pollTimeoutID: null
}
},
computed: {
isUnpayable () {
return STATUS_UNPAYABLE.includes(this.srvModel.status);
isInvalid () {
return STATUS_INVALID.includes(this.srvModel.status);
},
isPaid () {
isSettled () {
return STATUS_SETTLED.includes(this.srvModel.status);
},
isProcessing () {
return STATUS_PAID.includes(this.srvModel.status);
},
isActive () {
return !this.isUnpayable && !this.isPaid;
return STATUS_PAYABLE.includes(this.srvModel.status);
},
showInfo () {
return this.showTimer || this.showPaymentDueInfo;
@ -141,16 +138,16 @@ function initApp() {
return this.btcPaid > 0 && this.btcDue > 0;
},
showRecommendedFee () {
return this.isActive() && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
return this.isActive && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
},
orderAmount () {
return parseFloat(this.srvModel.orderAmount);
return this.asNumber(this.srvModel.orderAmount);
},
btcDue () {
return parseFloat(this.srvModel.btcDue);
return this.asNumber(this.srvModel.btcDue);
},
btcPaid () {
return parseFloat(this.srvModel.btcPaid);
return this.asNumber(this.srvModel.btcPaid);
},
pmId () {
return this.paymentMethodId || this.srvModel.paymentMethodId;
@ -181,13 +178,26 @@ function initApp() {
},
isPluginPaymentMethod () {
return !this.paymentMethodIds.includes(this.pmId);
},
realCryptoCode () {
return this.srvModel.cryptoCode.toLowerCase() === 'sats' ? 'BTC' : this.srvModel.cryptoCode;
}
},
watch: {
isPaid: function (newValue, oldValue) {
isProcessing: function (newValue, oldValue) {
if (newValue === true && oldValue === false) {
// poll from here on
this.listenForConfirmations();
}
},
isSettled: function (newValue, oldValue) {
if (newValue === true && oldValue === false) {
const duration = 5000;
const self = this;
// stop polling
if (this.pollTimeoutID) {
clearTimeout(this.pollTimeoutID);
}
// celebration!
Vue.nextTick(function () {
self.celebratePayment(duration);
@ -208,9 +218,12 @@ function initApp() {
mounted () {
this.updateData(this.srvModel);
this.updateTimer();
if (this.isActive) {
if (this.isActive || this.isProcessing) {
this.listenIn();
}
if (this.isProcessing) {
this.listenForConfirmations();
}
updateLanguageSelect();
window.parent.postMessage('loaded', '*');
},
@ -224,6 +237,9 @@ function initApp() {
changeLanguage (e) {
updateLanguage(e.target.value);
},
asNumber (val) {
return parseFloat(val.replace(/\s/g, '')); // e.g. sats are formatted with spaces: 1 000 000
},
padTime (val) {
return val.toString().padStart(2, '0');
},
@ -257,13 +273,26 @@ function initApp() {
}
}
// fallback in case there is no websocket support
(function watcher() {
setTimeout(async function () {
if (socket === null || socket.readyState !== 1) {
if (!socket || socket.readyState !== 1) {
this.pollUpdates(2000, socket)
}
},
listenForConfirmations () {
this.pollUpdates(30000);
},
pollUpdates (interval, socket) {
const self = this;
const updateFn = this.fetchData;
if (self.pollTimeoutID) {
clearTimeout(self.pollTimeoutID);
}
(function pollFn() {
self.pollTimeoutID = setTimeout(async function () {
if (!socket || socket.readyState !== 1) {
await updateFn();
pollFn();
}
watcher();
}, 2000);
}, interval);
})();
},
async fetchData () {

View file

@ -26,6 +26,9 @@
"address": "Address",
"lightning": "Lightning",
"payment_link": "Payment Link",
"payment_sent": "Payment Sent",
"payment_sent_body": "Your payment has been received and is now processing.",
"payment_sent_confirmations": "Currently it has {{receivedConfirmations}} confirmations. Once it receives {{requiredConfirmations}} confirmations on the {{cryptoCode}} blockchain, the status will be updated to settled.",
"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.",
@ -34,5 +37,5 @@
"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 {{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."
}