diff --git a/BTCPayServer.Tests/Extensions.cs b/BTCPayServer.Tests/Extensions.cs index b35c89a10..acfad75d9 100644 --- a/BTCPayServer.Tests/Extensions.cs +++ b/BTCPayServer.Tests/Extensions.cs @@ -131,7 +131,14 @@ namespace BTCPayServer.Tests var element = driver.FindElement(selector); if ((value && !element.Selected) || (!value && element.Selected)) { - driver.WaitForAndClick(selector); + try + { + driver.WaitForAndClick(selector); + } + catch (ElementClickInterceptedException) + { + element.SendKeys(" "); + } } if (value != element.Selected) diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 54e07b36d..3a513816e 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -316,7 +316,10 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id("Password")).SendKeys(password); Driver.FindElement(By.Id("LoginButton")).Click(); } - + public void GoToApps() + { + Driver.FindElement(By.Id("Apps")).Click(); + } public void GoToStores() { Driver.FindElement(By.Id("Stores")).Click(); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 73777c77e..873595d2d 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1218,7 +1218,50 @@ namespace BTCPayServer.Tests Assert.Contains(bolt, s.Driver.PageSource); } } - + + [Fact] + [Trait("Selenium", "Selenium")] + [Trait("Lightning", "Lightning")] + public async Task CanUsePOSPrint() + { + using var s = SeleniumTester.Create(); + s.Server.ActivateLightning(); + await s.StartAsync(); + + await s.Server.EnsureChannelsSetup(); + + s.RegisterNewUser(true); + var store = s.CreateNewStore(); + var network = s.Server.NetworkProvider.GetNetwork("BTC").NBitcoinNetwork; + s.GoToStore(store.storeId); + s.AddLightningNode("BTC", LightningConnectionType.CLightning, false); + s.Driver.FindElement(By.Id($"Modify-LightningBTC")).Click(); + s.Driver.SetCheckbox(By.Id("LNURLEnabled"), true); + s.GoToApps(); + s.Driver.FindElement(By.Id("CreateNewApp")).Click(); + s.Driver.FindElement(By.Id("SelectedAppType")).Click(); + s.Driver.FindElement(By.CssSelector("option[value='PointOfSale']")).Click(); + s.Driver.FindElement(By.Id("Name")).SendKeys(Guid.NewGuid().ToString()); + s.Driver.FindElement(By.Id("Create")).Click(); + s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); + s.Driver.FindElement(By.Id("DefaultView")).Click(); + s.Driver.FindElement(By.CssSelector("option[value='3']")).Click(); + s.Driver.FindElement(By.Id("SaveSettings")).Click(); + s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); + + s.Driver.FindElement(By.Id("ViewApp")).Click(); + var btns = s.Driver.FindElements(By.ClassName("lnurl")); + foreach (IWebElement webElement in btns) + { + var choice = webElement.GetAttribute("data-choice"); + var lnurl = webElement.GetAttribute("href"); + var parsed = LNURL.LNURL.Parse(lnurl, out _); + Assert.True(parsed.ToString().EndsWith(choice)); + Assert.IsType(await LNURL.LNURL.FetchInformation(parsed, new HttpClient())); + } + + } + [Fact] [Trait("Selenium", "Selenium")] [Trait("Lightning", "Lightning")] diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 6111e9280..e26ac90f7 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -63,7 +63,7 @@ - + diff --git a/BTCPayServer/Controllers/AppsPublicController.cs b/BTCPayServer/Controllers/AppsPublicController.cs index e05bf7d25..45caf01b9 100644 --- a/BTCPayServer/Controllers/AppsPublicController.cs +++ b/BTCPayServer/Controllers/AppsPublicController.cs @@ -100,6 +100,7 @@ namespace BTCPayServer.Controllers CustomCSSLink = settings.CustomCSSLink, CustomLogoLink = storeBlob.CustomLogo, AppId = appId, + Store = store, Description = settings.Description, EmbeddedCSS = settings.EmbeddedCSS, RequiresRefundEmail = settings.RequiresRefundEmail diff --git a/BTCPayServer/LNURL/LNURLController.cs b/BTCPayServer/LNURL/LNURLController.cs index ae35b84b5..a9e460b26 100644 --- a/BTCPayServer/LNURL/LNURLController.cs +++ b/BTCPayServer/LNURL/LNURLController.cs @@ -2,9 +2,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; @@ -18,7 +23,9 @@ using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using LNURL; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using NBitcoin; using NBitcoin.Crypto; using Newtonsoft.Json; @@ -35,6 +42,8 @@ namespace BTCPayServer private readonly StoreRepository _storeRepository; private readonly AppService _appService; private readonly InvoiceController _invoiceController; + private readonly SettingsRepository _settingsRepository; + private readonly LinkGenerator _linkGenerator; public LNURLController(InvoiceRepository invoiceRepository, EventAggregator eventAggregator, @@ -42,7 +51,9 @@ namespace BTCPayServer LightningLikePaymentHandler lightningLikePaymentHandler, StoreRepository storeRepository, AppService appService, - InvoiceController invoiceController) + InvoiceController invoiceController, + SettingsRepository settingsRepository, + LinkGenerator linkGenerator) { _invoiceRepository = invoiceRepository; _eventAggregator = eventAggregator; @@ -51,8 +62,163 @@ namespace BTCPayServer _storeRepository = storeRepository; _appService = appService; _invoiceController = invoiceController; + _settingsRepository = settingsRepository; + _linkGenerator = linkGenerator; } - + + + [HttpGet("pay/app/{appId}/{itemCode}")] + public async Task GetLNURLForApp(string cryptoCode, string appId, string itemCode = null) + { + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null || !network.SupportLightning) + { + return NotFound(); + } + + var app = await _appService.GetApp(appId, null, true); + if (app is null) + { + return NotFound(); + } + + var store = app.StoreData; + if (store is null) + { + return NotFound(); + } + if (string.IsNullOrEmpty(itemCode)) + { + return NotFound(); + } + + var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); + var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); + var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); + var lnUrlMethod = + methods.FirstOrDefault(method => method.PaymentId == pmi) as LNURLPaySupportedPaymentMethod; + var lnMethod = methods.FirstOrDefault(method => method.PaymentId == lnpmi); + if (lnUrlMethod is null || lnMethod is null) + { + return NotFound(); + } + + ViewPointOfSaleViewModel.Item[] items = { }; + string currencyCode = null; + switch (app.AppType) + { + case nameof(AppType.Crowdfund): + var cfS = app.GetSettings(); + currencyCode = cfS.TargetCurrency; + items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency); + break; + case nameof(AppType.PointOfSale): + var posS = app.GetSettings(); + currencyCode = posS.Currency; + items = _appService.Parse(posS.Template, posS.Currency); + break; + } + + var item = items.FirstOrDefault(item1 => + item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase)); + if (item is null || + item.Inventory <= 0 || + (item.PaymentMethods?.Any() is true && + item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false)) + { + return NotFound(); + } + + return await GetLNURL(cryptoCode, app.StoreDataId, currencyCode, null, null, + () => (null, new List { AppService.GetAppInternalTag(appId) }, item.Price.Value, true)); + } + + + [HttpGet("pay")] + public async Task GetLNURL(string cryptoCode, string storeId, string currencyCode = null, + decimal? min = null, decimal? max = null, + Func<(string username, List additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice)> + internalDetails = null) + { + currencyCode ??= cryptoCode; + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null || !network.SupportLightning) + { + return NotFound(); + } + + var store = await _storeRepository.FindStore(storeId); + if (store is null) + { + return NotFound(); + } + + var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); + var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); + var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); + var lnUrlMethod = + methods.FirstOrDefault(method => method.PaymentId == pmi) as LNURLPaySupportedPaymentMethod; + var lnMethod = methods.FirstOrDefault(method => method.PaymentId == lnpmi); + if (lnUrlMethod is null || lnMethod is null) + { + return NotFound(); + } + + var blob = store.GetStoreBlob(); + if (blob.GetExcludedPaymentMethods().Match(pmi) || blob.GetExcludedPaymentMethods().Match(lnpmi)) + { + return NotFound(); + } + + (string username, List additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice) = + (internalDetails ?? (() => (null, null, null, null)))(); + + if ((anyoneCanInvoice ?? blob.AnyoneCanInvoice) is false) + { + return NotFound(); + } + + List lnurlMetadata = new List(); + + var i = await _invoiceController.CreateInvoiceCoreRaw( + new CreateInvoiceRequest + { + Amount = invoiceAmount, + Checkout = new InvoiceDataBase.CheckoutOptions + { + PaymentMethods = new[] { pmi.ToStringNormalized() }, + Expiration = blob.InvoiceExpiration < TimeSpan.FromMinutes(2) + ? blob.InvoiceExpiration + : TimeSpan.FromMinutes(2) + }, + Currency = currencyCode, + Type = invoiceAmount is null ? InvoiceType.TopUp : InvoiceType.Standard, + }, store, Request.GetAbsoluteUri(""), additionalTags); + if (i.Type != InvoiceType.TopUp) + { + min = i.GetPaymentMethod(pmi).Calculate().Due.ToDecimal(MoneyUnit.Satoshi); + max = min; + } + + lnurlMetadata.Add(new[] { "text/plain", i.Id }); + return Ok(new LNURLPayRequest + { + Tag = "payRequest", + MinSendable = new LightMoney(min ?? 1m, LightMoneyUnit.Satoshi), + MaxSendable = + max is null + ? LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC) + : new LightMoney(max.Value, LightMoneyUnit.Satoshi), + CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0, + Metadata = JsonConvert.SerializeObject(lnurlMetadata), + Callback = new Uri(_linkGenerator.GetUriByAction( + action: nameof(GetLNURLForInvoice), + controller: "LNURL", + values: new { cryptoCode, invoiceId = i.Id }, Request.Scheme, Request.Host, Request.PathBase)) + }); + } + + [HttpGet("pay/i/{invoiceId}")] public async Task GetLNURLForInvoice(string invoiceId, string cryptoCode, [FromQuery] long? amount = null, string comment = null) @@ -137,7 +303,7 @@ namespace BTCPayServer }); } } - catch (Exception) + catch (Exception e) { return BadRequest(new LNUrlStatusResponse { diff --git a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs index 25741d4b9..12a024348 100644 --- a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using BTCPayServer.Data; using BTCPayServer.Services.Apps; namespace BTCPayServer.Models.AppViewModels @@ -65,5 +66,7 @@ namespace BTCPayServer.Models.AppViewModels [Display(Name = "Custom CSS Code")] public string EmbeddedCSS { get; set; } public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore; + + public StoreData Store { get; set; } } } diff --git a/BTCPayServer/Services/Apps/AppType.cs b/BTCPayServer/Services/Apps/AppType.cs index cfc162165..325aae6e2 100644 --- a/BTCPayServer/Services/Apps/AppType.cs +++ b/BTCPayServer/Services/Apps/AppType.cs @@ -16,7 +16,8 @@ namespace BTCPayServer.Services.Apps [Display(Name = "Item list and cart")] Cart, [Display(Name = "Keypad only")] - Light + Light, + Print } public enum RequiresRefundEmail diff --git a/BTCPayServer/Views/AppsPublic/PointOfSale/Print.cshtml b/BTCPayServer/Views/AppsPublic/PointOfSale/Print.cshtml new file mode 100644 index 000000000..a9195654f --- /dev/null +++ b/BTCPayServer/Views/AppsPublic/PointOfSale/Print.cshtml @@ -0,0 +1,122 @@ +@using BTCPayServer.Models.AppViewModels +@using BTCPayServer.Payments.Lightning +@using LNURL +@inject BTCPayNetworkProvider BTCPayNetworkProvider +@model BTCPayServer.Models.AppViewModels.ViewPointOfSaleViewModel + +@{ + Context.Request.Query.TryGetValue("cryptocode", out var cryptoCodeValues); + var cryptoCode = cryptoCodeValues.FirstOrDefault() ?? "BTC"; + Layout = "_LayoutPos"; + var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue); + var supported = Model.Store.GetSupportedPaymentMethods(BTCPayNetworkProvider).OfType().OrderBy(method => method.CryptoCode == cryptoCode).FirstOrDefault(); + if (supported != null && !Model.Store.GetEnabledPaymentIds(BTCPayNetworkProvider).Contains(supported.PaymentId)) + { + supported = null; + } + + +} +@if (supported is null) +{ + +} +
+
+ + +

@Model.Title

+ @if (!string.IsNullOrEmpty(Model.Description)) + { +
+
@Safe.Raw(Model.Description)
+
+ } +
+ @for (int x = 0; x < Model.Items.Length; x++) + { + var item = Model.Items[x]; + var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText; + buttonText = buttonText.Replace("{0}",item.Price.Formatted) + ?.Replace("{Price}",item.Price.Formatted); + +
+ @if (!String.IsNullOrWhiteSpace(item.Image)) + { + + } + @{CardBody(item.Title, item.Description);} + +
+ } +
+
+
+ +@functions { + private void CardBody(string title, string description) + { +
+
@title
+ @if (!String.IsNullOrWhiteSpace(description)) + { +

@Safe.Raw(description)

+ } +
+ } +}