Make NFC built in (#4541)

* Make NFC built int

* support checkout v2

* uninstall old plugin

* fix lnurl in unified checkout

* fix tests

* fix tests

* fix old checkout unified qr

* clean up and make nfc submission more sturdy

* support topup invoices for lnurlw

* fix test

* Payment URI fixes

* Fix LNURL exclusion cases

* UI updates

* Adapt test

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri
2023-02-08 07:47:38 +01:00
committed by GitHub
parent 67254cc30c
commit 85513aa5c3
13 changed files with 421 additions and 53 deletions

View File

@@ -61,14 +61,14 @@ namespace BTCPayServer.Tests
Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text); Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text);
var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl); Assert.StartsWith("bitcoin:", payUrl);
Assert.DoesNotContain("&lightning=", payUrl); Assert.DoesNotContain("lightning=", payUrl);
// Switch to LNURL // Switch to LNURL
s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click(); s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click();
TestUtils.Eventually(() => TestUtils.Eventually(() =>
{ {
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("lightning:", payUrl); Assert.StartsWith("lightning:lnurl", payUrl);
}); });
// Default payment method // Default payment method
@@ -78,9 +78,9 @@ namespace BTCPayServer.Tests
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count); Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count);
Assert.Contains("Lightning", s.Driver.WaitForElement(By.CssSelector(".payment-method.active")).Text); Assert.Contains("Lightning", s.Driver.WaitForElement(By.CssSelector(".payment-method.active")).Text);
Assert.DoesNotContain("LNURL", s.Driver.PageSource); Assert.Contains("Bitcoin", s.Driver.WaitForElement(By.CssSelector(".payment-method")).Text);
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("lightning:", payUrl); Assert.StartsWith("lightning:lnbcrt", payUrl);
// Lightning amount in Sats // Lightning amount in Sats
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text); Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
@@ -188,17 +188,16 @@ namespace BTCPayServer.Tests
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method"))); Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl); Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&lightning=", payUrl); Assert.Contains("&lightning=lnbcrt", payUrl);
// BIP21 with topup invoice // BIP21 with topup invoice
s.GoToHome(); s.GoToHome();
invoiceId = s.CreateInvoice(amount: null); invoiceId = s.CreateInvoice(amount: null);
s.GoToInvoiceCheckout(invoiceId); s.GoToInvoiceCheckout(invoiceId);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count); Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
Assert.Contains("Bitcoin", s.Driver.FindElement(By.CssSelector(".payment-method.active")).Text);
Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text);
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.DoesNotContain("&lightning=", payUrl); Assert.StartsWith("bitcoin:", payUrl);
Assert.DoesNotContain("&lightning=lnurl", payUrl);
// Expiry message should not show amount for topup invoice // Expiry message should not show amount for topup invoice
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds")); expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));

View File

@@ -649,12 +649,13 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
var btcId = PaymentMethodId.Parse("BTC"); var btcId = PaymentMethodId.Parse("BTC");
var lnId = PaymentMethodId.Parse("BTC_LightningLike"); var lnId = PaymentMethodId.Parse("BTC_LightningLike");
var lnurlId = PaymentMethodId.Parse("BTC_LNURLPAY");
if (paymentMethodId is null) if (paymentMethodId is null)
{ {
var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider) var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider)
// Exclude LNURL for Checkout v2 + non-top up invoices .Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 ||
.Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 || // Exclude LNURL for Checkout v2 + non-top up invoices
pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()) (pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()))
.ToArray(); .ToArray();
// Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods // Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods
@@ -802,11 +803,9 @@ namespace BTCPayServer.Controllers
IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1,
StoreId = store.Id, StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentMethods() AvailableCryptos = invoice.GetPaymentMethods()
.Where(i => i.Network != null && .Where(i => i.Network != null && storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for Checkout v2 + non-top up invoices // Exclude LNURL for Checkout v2 + non-top up invoices
(storeBlob.CheckoutType == CheckoutType.V1 || i.GetId().PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp())
i.GetId().PaymentType is not LNURLPayPaymentType ||
invoice.IsUnsetTopUp()))
.Select(kv => .Select(kv =>
{ {
var availableCryptoPaymentMethodId = kv.GetId(); var availableCryptoPaymentMethodId = kv.GetId();
@@ -836,10 +835,15 @@ namespace BTCPayServer.Controllers
{ {
var onchainPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == btcId.ToString()); var onchainPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == btcId.ToString());
var lightningPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnId.ToString()); var lightningPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnId.ToString());
var lnurlPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnurlId.ToString());
if (onchainPM != null && lightningPM != null) if (onchainPM != null && lightningPM != null)
{ {
model.AvailableCryptos.Remove(lightningPM); model.AvailableCryptos.Remove(lightningPM);
} }
if (onchainPM != null && lnurlPM != null)
{
model.AvailableCryptos.Remove(lnurlPM);
}
} }
paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob, paymentMethod); paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob, paymentMethod);

View File

@@ -504,11 +504,8 @@ namespace BTCPayServer.Controllers
} }
blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1; blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1;
if (blob.CheckoutType == Client.Models.CheckoutType.V2)
{
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
}
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.RequiresRefundEmail = model.RequiresRefundEmail;
blob.LazyPaymentMethods = model.LazyPaymentMethods; blob.LazyPaymentMethods = model.LazyPaymentMethods;
blob.RedirectAutomatically = model.RedirectAutomatically; blob.RedirectAutomatically = model.RedirectAutomatically;

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Common;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Logging; using BTCPayServer.Logging;
@@ -11,6 +10,7 @@ using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBXplorer.Models; using NBXplorer.Models;
@@ -69,13 +69,24 @@ namespace BTCPayServer.Payments.Bitcoin
{ {
var lightningInfo = invoiceResponse.CryptoInfo.FirstOrDefault(a => var lightningInfo = invoiceResponse.CryptoInfo.FirstOrDefault(a =>
a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LightningLike)); a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LightningLike));
if (lightningInfo is not null && !string.IsNullOrEmpty(lightningInfo.PaymentUrls?.BOLT11))
{
// Turn the colon into an equal sign to trun the whole into the lightning part of the query string lightningFallback = lightningInfo.PaymentUrls.BOLT11;
}
// lightningInfo?.PaymentUrls?.BOLT11: lightning:lnbcrt440070n1p3ua9np... else
lightningFallback = lightningInfo?.PaymentUrls?.BOLT11.Replace("lightning:", "lightning=", StringComparison.OrdinalIgnoreCase); {
// lightningFallback: lightning=lnbcrt440070n1p3ua9np... var lnurl = invoiceResponse.CryptoInfo.FirstOrDefault(a =>
a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LNURLPay));
if (lnurl is not null)
{
lightningFallback = LNURL.LNURL.EncodeUri(new Uri(lnurl.Url), "payRequest", true).ToString();
}
}
if (!string.IsNullOrEmpty(lightningFallback))
{
lightningFallback = lightningFallback
.Replace("lightning:", "lightning=", StringComparison.OrdinalIgnoreCase);
}
} }
if (model.Activated) if (model.Activated)

View File

@@ -0,0 +1,183 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
namespace BTCPayServer.Plugins.NFC
{
[Route("plugins/NFC")]
public class NFCController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly InvoiceRepository _invoiceRepository;
private readonly UILNURLController _uilnurlController;
private readonly InvoiceActivator _invoiceActivator;
private readonly StoreRepository _storeRepository;
public NFCController(IHttpClientFactory httpClientFactory,
InvoiceRepository invoiceRepository,
UILNURLController uilnurlController,
InvoiceActivator invoiceActivator,
StoreRepository storeRepository)
{
_httpClientFactory = httpClientFactory;
_invoiceRepository = invoiceRepository;
_uilnurlController = uilnurlController;
_invoiceActivator = invoiceActivator;
_storeRepository = storeRepository;
}
public class SubmitRequest
{
public string Lnurl { get; set; }
public string InvoiceId { get; set; }
public long? Amount { get; set; }
}
[AllowAnonymous]
public async Task<IActionResult> SubmitLNURLWithdrawForInvoice([FromBody] SubmitRequest request)
{
var invoice = await _invoiceRepository.GetInvoice(request.InvoiceId);
if (invoice?.Status is not InvoiceStatusLegacy.New)
{
return NotFound();
}
var methods = invoice.GetPaymentMethods();
PaymentMethod lnPaymentMethod = null;
if (!methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LNURLPay), out var lnurlPaymentMethod) &&
!methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LightningLike), out lnPaymentMethod))
{
return BadRequest("destination for lnurlw was not specified");
}
Uri uri;
string tag;
try
{
uri = LNURL.LNURL.Parse(request.Lnurl, out tag);
if (uri is null)
{
return BadRequest("lnurl was malformed");
}
}
catch (Exception e)
{
return BadRequest(e.Message);
}
if (!string.IsNullOrEmpty(tag) && !tag.Equals("withdrawRequest"))
{
return BadRequest("lnurl was not lnurl-withdraw");
}
var httpClient = _httpClientFactory.CreateClient(uri.IsOnion()
? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient
: LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient);
var info = (await
LNURL.LNURL.FetchInformation(uri, "withdrawRequest", httpClient)) as LNURLWithdrawRequest;
if (info?.Callback is null)
{
return BadRequest("Could not fetch info from lnurl-withdraw ");
}
httpClient = _httpClientFactory.CreateClient(info.Callback.IsOnion()
? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient
: LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient);
string bolt11 = null;
if (lnPaymentMethod is not null)
{
if (lnPaymentMethod.GetPaymentMethodDetails() is LightningLikePaymentMethodDetails
{
Activated: false
} lnPMD)
{
var store = await _storeRepository.FindStore(invoice.StoreId);
await _invoiceActivator.ActivateInvoicePaymentMethod(lnPaymentMethod.GetId(), invoice, store);
}
lnPMD = lnPaymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails;
Money due;
if (invoice.Type == InvoiceType.TopUp && request.Amount is not null)
{
due = new Money(request.Amount.Value, MoneyUnit.Satoshi);
}else if (invoice.Type == InvoiceType.TopUp)
{
return BadRequest("This is a topup invoice and you need to provide the amount in sats to pay.");
}
else
{
due = lnPaymentMethod.Calculate().Due;
}
if (info.MinWithdrawable > due || due > info.MaxWithdrawable)
{
return BadRequest("invoice amount is not payable with the lnurl allowed amounts.");
}
if (lnPMD?.Activated is true)
{
bolt11 = lnPMD.BOLT11;
}
}
if (lnurlPaymentMethod is not null)
{
Money due;
if (invoice.Type == InvoiceType.TopUp && request.Amount is not null)
{
due = new Money(request.Amount.Value, MoneyUnit.Satoshi);
}else if (invoice.Type == InvoiceType.TopUp)
{
return BadRequest("This is a topup invoice and you need to provide the amount in sats to pay.");
}
else
{
due = lnurlPaymentMethod.Calculate().Due;
}
var response = await _uilnurlController.GetLNURLForInvoice(request.InvoiceId, "BTC",
due.Satoshi);
if (response is ObjectResult objectResult)
{
switch (objectResult.Value)
{
case LNURLPayRequest.LNURLPayRequestCallbackResponse lnurlPayRequestCallbackResponse:
bolt11 = lnurlPayRequestCallbackResponse.Pr;
break;
case LNUrlStatusResponse lnUrlStatusResponse:
return BadRequest(
$"Could not fetch bolt11 invoice to pay to: {lnUrlStatusResponse.Reason}");
}
}
}
if (bolt11 is null)
{
return BadRequest("Could not fetch bolt11 invoice to pay to.");
}
var result = await info.SendRequest(bolt11, httpClient);
if (result.Status.Equals("ok", StringComparison.InvariantCultureIgnoreCase))
{
return Ok(result.Reason);
}
return BadRequest(result.Reason);
}
}
}

View File

@@ -0,0 +1,31 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Plugins.NFC
{
public class NFCPlugin : BaseBTCPayServerPlugin
{
public override string Identifier => "BTCPayServer.Plugins.NFC";
public override string Name => "NFC";
public override string Description => "Allows you to support contactless card payments over NFC and LNURL Withdraw!";
public override void Execute(IServiceCollection applicationBuilder)
{
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("NFC/CheckoutEnd",
"checkout-end"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("NFC/LNURLNFCPostContent",
"checkout-lightning-post-content"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("NFC/CheckoutEnd",
"checkout-v2-end"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("NFC/LNURLNFCPostContent",
"checkout-v2-lightning-post-content"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("NFC/LNURLNFCPostContent",
"checkout-v2-bitcoin-post-content"));
base.Execute(applicationBuilder);
}
}
}

View File

@@ -120,6 +120,12 @@ namespace BTCPayServer.Plugins
foreach (var toLoad in pluginsToLoad) foreach (var toLoad in pluginsToLoad)
{ {
// This used to be a standalone plugin but due to popular demand has been made as part of core. If we detect an install, we remove the redundant plugin.
if (toLoad.PluginIdentifier == "BTCPayServer.Plugins.NFC")
{
QueueCommands(pluginsFolder, ("delete", toLoad.PluginIdentifier));
continue;
}
if (!loadedPluginIdentifiers.Add(toLoad.PluginIdentifier)) if (!loadedPluginIdentifiers.Add(toLoad.PluginIdentifier))
continue; continue;
try try

View File

@@ -2,6 +2,7 @@
@model BTCPayServer.Models.InvoicingModels.PaymentModel @model BTCPayServer.Models.InvoicingModels.PaymentModel
<template id="bitcoin-method-checkout-template"> <template id="bitcoin-method-checkout-template">
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-bitcoin-pre-content", model = Model})
<div class="payment-box"> <div class="payment-box">
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId"> <div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div> <div>
@@ -17,15 +18,16 @@
</div> </div>
<button type="button" class="btn btn-link" data-clipboard-target="#Address_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button> <button type="button" class="btn btn-link" data-clipboard-target="#Address_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button>
</div> </div>
<div v-if="BOLT11" class="input-group mt-3"> <div v-if="lightning" class="input-group mt-3">
<div class="form-floating"> <div class="form-floating">
<input id="BOLT11_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="BOLT11" /> <input id="Lightning_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="lightning" />
<label for="BOLT11_@Model.PaymentMethodId" v-t="'lightning'"></label> <label for="Lightning_@Model.PaymentMethodId" v-t="'lightning'"></label>
</div> </div>
<button type="button" class="btn btn-link" data-clipboard-target="#BOLT11_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button> <button type="button" class="btn btn-link" data-clipboard-target="#Lightning_@Model.PaymentMethodId" :data-clipboard-confirm="$t('copy_confirm')" v-t="'copy'"></button>
</div> </div>
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" <a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top"
:href="model.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')" v-t="'pay_in_wallet'"></a> :href="model.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')" v-t="'pay_in_wallet'"></a>
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-bitcoin-post-content", model = Model})
</div> </div>
</template> </template>
@@ -44,8 +46,8 @@
hasPayjoin () { hasPayjoin () {
return this.model.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1; return this.model.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1;
}, },
BOLT11 () { lightning () {
const match = this.model.invoiceBitcoinUrl.match(/&lightning=(.*)&?/i); const match = this.model.invoiceBitcoinUrl.match(/[&?]lightning=(.*)&?/i);
return match ? match[1].toLowerCase() : null; return match ? match[1].toLowerCase() : null;
} }
} }

View File

@@ -2,6 +2,7 @@
<template id="lightning-method-checkout-template"> <template id="lightning-method-checkout-template">
<div class="payment-box"> <div class="payment-box">
@await Component.InvokeAsync("UiExtensionPoint" , new { location="checkout-v2-lightning-pre-content", model = Model})
<div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId"> <div v-if="model.invoiceBitcoinUrlQR" class="qr-container" :data-clipboard="model.btcAddress" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId">
<div> <div>
<qrcode :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" /> <qrcode :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
@@ -18,6 +19,7 @@
</div> </div>
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" <a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100 mt-4" target="_top"
:href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a> :href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a>
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-lightning-post-content", model = Model})
</div> </div>
</template> </template>

View File

@@ -0,0 +1,133 @@
@using BTCPayServer.Abstractions.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
<template id="lnurl-withdraw-template">
<button v-if="v2" class="btn btn-secondary rounded-pill w-100 mt-4" type="button"
v-on:click="startScan"
v-bind:disabled="scanning || submitting"
v-bind:class="{ 'loading': scanning || submitting, 'text-secondary': !supported }">{{btnText}}</button>
<bp-loading-button v-else>
<button v-on:click="startScan" style="margin-top: 15px;" v-bind:disabled="scanning || submitting"
v-bind:class="{ 'loading': scanning || submitting, 'action-button': supported, 'btn btn-text w-100': !supported }">
<span class="button-text">{{btnText}}</span>
<div class="loader-wrapper">
<partial name="Checkout-Spinner" />
</div>
</button>
</bp-loading-button>
</template>
<script type="text/javascript">
Vue.component("lnurl-withdraw-checkout", {
template: "#lnurl-withdraw-template",
computed: {
v2: function() {
return !!this.$parent.model;
},
topup: function (){
return (this.$parent.srvModel || this.$parent.model).isUnsetTopUp;
},
destination: function (){
return (this.$parent.srvModel || this.$parent.model).invoiceId;
},
btnText: function (){
return this.supported? "Pay by NFC & LNURL-Withdraw" : "Pay by LNURL-Withdraw";
}
},
data: function () {
return {
url : @Safe.Json(Context.Request.GetAbsoluteUri(Url.Action("SubmitLNURLWithdrawForInvoice", "NFC"))),
supported: ('NDEFReader' in window && window.self === window.top),
scanning: false,
submitting: false,
readerAbortController: null,
amount: 0
}
},
methods: {
startScan: async function () {
try {
if (this.scanning || this.submitting) {
return;
}
if (this.topup) {
const amountStr = prompt("How many sats do you want to pay?")
if (amountStr){
try {
this.amount = parseInt(amountStr)
} catch {
alert("Please provide a valid number amount in sats");
}
}else{
return;
}
}
const self = this;
self.submitting = false;
self.scanning = true;
if (!this.supported) {
const result = prompt("Enter LNURL withdraw");
if (result) {
self.sendData.bind(self)(result);
return;
}
self.scanning = false;
}
ndef = new NDEFReader()
self.readerAbortController = new AbortController()
await ndef.scan({signal: self.readerAbortController.signal})
ndef.addEventListener('readingerror', () => {
self.scanning = false;
self.readerAbortController.abort()
})
ndef.addEventListener('reading', ({message, serialNumber}) => {
//Decode NDEF data from tag
const record = message.records[0]
const textDecoder = new TextDecoder('utf-8')
const lnurl = textDecoder.decode(record.data)
//User feedback, show loader icon
self.scanning = false;
self.sendData.bind(self)(lnurl);
})
} catch(e) {
self.scanning = false;
self.submitting = false;
}
},
sendData: function (lnurl) {
this.submitting = true;
//Post LNURLW data to server
var xhr = new XMLHttpRequest()
xhr.open('POST', this.url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(JSON.stringify({lnurl, invoiceId: this.destination, amount: this.amount}))
const self = this;
//User feedback, reset on failure
xhr.onload = function () {
if (xhr.readyState === xhr.DONE) {
console.log(xhr.response);
console.log(xhr.responseText);
self.scanning = false;
self.submitting = false;
if(self.readerAbortController) {
self.readerAbortController.abort()
}
if(xhr.response){
alert(xhr.response)
}
}
}
}
}
});
</script>

View File

@@ -0,0 +1,3 @@
<lnurl-withdraw-checkout>
</lnurl-withdraw-checkout>

View File

@@ -47,7 +47,7 @@
<h5 class="text-center mt-1 mb-3 fw-semibold" v-if="srvModel.itemDesc" v-text="srvModel.itemDesc">@Model.ItemDesc</h5> <h5 class="text-center mt-1 mb-3 fw-semibold" v-if="srvModel.itemDesc" v-text="srvModel.itemDesc">@Model.ItemDesc</h5>
@if (Model.IsUnsetTopUp) @if (Model.IsUnsetTopUp)
{ {
<h2 id="AmountDue" class="text-center mb-3" v-t="'any_amount'"></h2> <h2 id="AmountDue" class="text-center" v-t="'any_amount'"></h2>
} }
else else
{ {
@@ -237,5 +237,6 @@
<partial name="@paymentMethodHandler.ExtensionPartial-v2" model="@Model"/> <partial name="@paymentMethodHandler.ExtensionPartial-v2" model="@Model"/>
} }
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-payment", model = Model }) @await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-payment", model = Model })
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-v2-end", model = Model })
</body> </body>
</html> </html>

View File

@@ -10,7 +10,7 @@
} }
@section PageFootContent { @section PageFootContent {
<partial name="_ValidationScriptsPartial"/> <partial name="_ValidationScriptsPartial" />
<script> <script>
delegate('click', '.setTheme', e => { delegate('click', '.setTheme', e => {
e.preventDefault(); e.preventDefault();
@@ -60,7 +60,7 @@
</table> </table>
</div> </div>
} }
<h3 class="mt-5 mb-3">Checkout</h3> <h3 class="mt-5 mb-3">Checkout</h3>
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<input asp-for="UseNewCheckout" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target=".checkout-settings" aria-expanded="@(Model.UseNewCheckout)" aria-controls="NewCheckoutSettings" /> <input asp-for="UseNewCheckout" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target=".checkout-settings" aria-expanded="@(Model.UseNewCheckout)" aria-controls="NewCheckoutSettings" />
@@ -71,30 +71,29 @@
</label> </label>
<span asp-validation-for="UseNewCheckout" class="text-danger"></span> <span asp-validation-for="UseNewCheckout" class="text-danger"></span>
<div class="form-text"> <div class="form-text">
Since v1.7.0 a new version of the checkout is available. Note: For now, the new version is English-only.<br/> Since v1.7.0 a new version of the checkout is available. Note: For now, the new version is English-only.<br />
We are still collecting <a href="https://github.com/btcpayserver/btcpayserver/discussions/4308" target="_blank" rel="noreferrer noopener">feedback</a> and offer this as an opt-in feature. We are still collecting <a href="https://github.com/btcpayserver/btcpayserver/discussions/4308" target="_blank" rel="noreferrer noopener">feedback</a> and offer this as an opt-in feature.
</div> </div>
</div> </div>
</div> </div>
<div class="checkout-settings collapse @(Model.UseNewCheckout ? "show" : "")" id="NewCheckoutSettings"> <div class="checkout-settings collapse @(Model.UseNewCheckout ? "show" : "")" id="NewCheckoutSettings">
<div class="form-check">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input"/>
<label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label>
<a href="https://bitcoinqr.dev/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
<div class="form-group"> <div class="form-group">
<label asp-for="DisplayExpirationTimer" class="form-label"></label> <label asp-for="DisplayExpirationTimer" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:10ch;"/> <input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:10ch;" />
<span class="input-group-text">minutes</span> <span class="input-group-text">minutes</span>
</div> </div>
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span> <span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>
</div> </div>
</div> </div>
<div class="form-check">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input" />
<label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label>
<a href="https://bitcoinqr.dev/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
<div class="checkout-settings collapse @(Model.UseNewCheckout ? "" : "show")" id="OldCheckoutSettings"> <div class="checkout-settings collapse @(Model.UseNewCheckout ? "" : "show")" id="OldCheckoutSettings">
<div class="form-check"> <div class="form-check">
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" /> <input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" />
@@ -102,7 +101,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="form-check"> <div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input"/> <input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input" />
<label asp-for="AutoDetectLanguage" class="form-check-label"></label> <label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<div class="form-text">Detects the language of the customer's browser with 99.9% accuracy.</div> <div class="form-text">Detects the language of the customer's browser with 99.9% accuracy.</div>
</div> </div>
@@ -132,7 +131,7 @@
<a href="#" class="setTheme" data-theme="dark">Dark</a> | <a href="#" class="setTheme" data-theme="dark">Dark</a> |
<a href="#" class="setTheme" data-theme="legacy">Legacy</a> <a href="#" class="setTheme" data-theme="legacy">Legacy</a>
</div> </div>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "invoice-checkout-theme-options", model = Model }) @await Component.InvokeAsync("UiExtensionPoint", new {location = "invoice-checkout-theme-options", model = Model})
</div> </div>
</div> </div>
@@ -141,7 +140,6 @@
<input asp-for="HtmlTitle" class="form-control" /> <input asp-for="HtmlTitle" class="form-control" />
<span asp-validation-for="HtmlTitle" class="text-danger"></span> <span asp-validation-for="HtmlTitle" class="text-danger"></span>
</div> </div>
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="LazyPaymentMethods" type="checkbox" class="form-check-input" /> <input asp-for="LazyPaymentMethods" type="checkbox" class="form-check-input" />
<label asp-for="LazyPaymentMethods" class="form-check-label"></label> <label asp-for="LazyPaymentMethods" class="form-check-label"></label>
@@ -150,7 +148,6 @@
<input asp-for="RedirectAutomatically" type="checkbox" class="form-check-input" /> <input asp-for="RedirectAutomatically" type="checkbox" class="form-check-input" />
<label asp-for="RedirectAutomatically" class="form-check-label"></label> <label asp-for="RedirectAutomatically" class="form-check-label"></label>
</div> </div>
<h3 class="mt-5 mb-3">Public receipt</h3> <h3 class="mt-5 mb-3">Public receipt</h3>
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" /> <input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" />
@@ -164,7 +161,6 @@
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="form-check-input" /> <input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label> <label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
</div> </div>
<button type="submit" class="btn btn-primary mt-4" id="Save">Save</button> <button type="submit" class="btn btn-primary mt-4" id="Save">Save</button>
</form> </form>
</div> </div>