From 3805b7f287796820dbae9a941270402210f5203d Mon Sep 17 00:00:00 2001 From: d11n Date: Wed, 2 Nov 2022 10:21:33 +0100 Subject: [PATCH] Checkout v2 (#4157) * Opt-in for new checkout * Update wording * Create invoice view update * Remove jQuery from checkout testing code * Checkout v2 basics * WIP * WIP 2 * Updates and fixes * Updates * Design updates * More design updates * Cheating and JS fixes * Use checkout form id whenever invoices get created * Improve email form handling * Cleanups * Payment method exclusion cases for Lightning and LNURL TODO: Cases and implementation need to be discussed * Introduce CheckoutType in API and replace UseNewCheckout in backend Co-authored-by: nicolas.dorier --- .../Models/CreateAppRequest.cs | 2 + BTCPayServer.Client/Models/InvoiceData.cs | 3 + BTCPayServer.Client/Models/StoreBaseData.cs | 10 + BTCPayServer.Tests/GreenfieldAPITests.cs | 5 +- .../GreenField/GreenfieldAppsController.cs | 2 + .../GreenField/GreenfieldInvoiceController.cs | 2 + .../GreenField/GreenfieldStoresController.cs | 4 + .../UIInvoiceController.Testing.cs | 24 +- .../Controllers/UIInvoiceController.UI.cs | 67 ++- .../Controllers/UIInvoiceController.cs | 4 +- .../Controllers/UIStoresController.cs | 29 +- BTCPayServer/Data/StoreBlob.cs | 3 + .../Models/BitpayCreateInvoiceRequest.cs | 3 + BTCPayServer/Models/InvoiceResponse.cs | 7 + .../InvoicingModels/CreateInvoiceModel.cs | 7 +- .../Models/InvoicingModels/PaymentModel.cs | 8 +- .../CheckoutAppearanceViewModel.cs | 11 + .../Controllers/UIPointOfSaleController.cs | 31 +- .../Models/UpdatePointOfSaleViewModel.cs | 7 + .../Services/Apps/PointOfSaleSettings.cs | 8 +- .../Services/Invoices/InvoiceEntity.cs | 7 + .../Services/Stores/CheckoutFormSelectList.cs | 55 ++ .../BitcoinLikeMethodCheckout-v2.cshtml | 29 + .../LightningLikeMethodCheckout-v2.cshtml | 23 + .../PointOfSale/UpdatePointOfSale.cshtml | 23 +- .../Views/UIInvoice/Checkout-Cheating.cshtml | 83 +++ .../Views/UIInvoice/CheckoutV2.cshtml | 545 ++++++++++++++++++ .../Views/UIInvoice/CreateInvoice.cshtml | 19 +- .../Views/UIStores/CheckoutAppearance.cshtml | 66 ++- BTCPayServer/wwwroot/checkout-v2/checkout.css | 152 +++++ BTCPayServer/wwwroot/checkout-v2/checkout.js | 60 ++ .../wwwroot/checkout/js/querystring.js | 7 +- BTCPayServer/wwwroot/img/icon-sprite.svg | 4 + BTCPayServer/wwwroot/js/copy-to-clipboard.js | 4 +- BTCPayServer/wwwroot/locales/en.json | 16 +- BTCPayServer/wwwroot/main/site.css | 4 +- .../swagger/v1/swagger.template.apps.json | 8 + .../swagger/v1/swagger.template.invoices.json | 19 + .../wwwroot/swagger/v1/swagger.template.json | 18 +- .../swagger/v1/swagger.template.stores.json | 8 + .../wwwroot/vendor/i18next/vue-i18next.js | 1 - 41 files changed, 1296 insertions(+), 92 deletions(-) create mode 100644 BTCPayServer/Services/Stores/CheckoutFormSelectList.cs create mode 100644 BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml create mode 100644 BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml create mode 100644 BTCPayServer/Views/UIInvoice/Checkout-Cheating.cshtml create mode 100644 BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml create mode 100644 BTCPayServer/wwwroot/checkout-v2/checkout.css create mode 100644 BTCPayServer/wwwroot/checkout-v2/checkout.js diff --git a/BTCPayServer.Client/Models/CreateAppRequest.cs b/BTCPayServer.Client/Models/CreateAppRequest.cs index 8ee6c321a..4cd9d22d6 100644 --- a/BTCPayServer.Client/Models/CreateAppRequest.cs +++ b/BTCPayServer.Client/Models/CreateAppRequest.cs @@ -36,6 +36,8 @@ namespace BTCPayServer.Client.Models public string RedirectUrl { get; set; } = null; public bool? RedirectAutomatically { get; set; } = null; public bool? RequiresRefundEmail { get; set; } = null; + public string CheckoutFormId { get; set; } = null; public string EmbeddedCSS { get; set; } = null; + public CheckoutType? CheckoutType { get; set; } = null; } } diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs index 808db6474..c7a7c5856 100644 --- a/BTCPayServer.Client/Models/InvoiceData.cs +++ b/BTCPayServer.Client/Models/InvoiceData.cs @@ -85,6 +85,9 @@ namespace BTCPayServer.Client.Models public bool? RedirectAutomatically { get; set; } public bool? RequiresRefundEmail { get; set; } = null; public string DefaultLanguage { get; set; } + [JsonProperty("checkoutFormId")] + public string CheckoutFormId { get; set; } + public CheckoutType? CheckoutType { get; set; } } } public class InvoiceData : InvoiceDataBase diff --git a/BTCPayServer.Client/Models/StoreBaseData.cs b/BTCPayServer.Client/Models/StoreBaseData.cs index 33bcc7152..b118e61e4 100644 --- a/BTCPayServer.Client/Models/StoreBaseData.cs +++ b/BTCPayServer.Client/Models/StoreBaseData.cs @@ -31,6 +31,10 @@ namespace BTCPayServer.Client.Models public bool AnyoneCanCreateInvoice { get; set; } public string DefaultCurrency { get; set; } public bool RequiresRefundEmail { get; set; } + + public string CheckoutFormId { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public CheckoutType CheckoutType { get; set; } public bool LightningAmountInSatoshi { get; set; } public bool LightningPrivateRouteHints { get; set; } public bool OnChainWithLnInvoiceFallback { get; set; } @@ -66,6 +70,12 @@ namespace BTCPayServer.Client.Models public IDictionary AdditionalData { get; set; } } + public enum CheckoutType + { + V1, + V2 + } + public enum NetworkFeeMode { MultiplePaymentsOnly, diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 407fedd38..292323ae6 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -19,6 +19,7 @@ using BTCPayServer.Services.Custodian.Client.MockCustodian; using BTCPayServer.Services; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; +using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -1324,12 +1325,14 @@ namespace BTCPayServer.Tests Checkout = new CreateInvoiceRequest.CheckoutOptions() { RedirectAutomatically = true, - RequiresRefundEmail = true + RequiresRefundEmail = true, + CheckoutFormId = GenericFormOption.Email.ToString() }, AdditionalSearchTerms = new string[] { "Banana" } }); Assert.True(newInvoice.Checkout.RedirectAutomatically); Assert.True(newInvoice.Checkout.RequiresRefundEmail); + Assert.Equal(GenericFormOption.Email.ToString(), newInvoice.Checkout.CheckoutFormId); Assert.Equal(user.StoreId, newInvoice.StoreId); //list var invoices = await viewOnly.GetInvoices(user.StoreId); diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs index daeca7c74..214428a33 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs @@ -164,6 +164,8 @@ namespace BTCPayServer.Controllers.Greenfield EmbeddedCSS = request.EmbeddedCSS, RedirectAutomatically = request.RedirectAutomatically, RequiresRefundEmail = BoolToRequiresRefundEmail(request.RequiresRefundEmail) ?? RequiresRefundEmail.InheritFromStore, + CheckoutFormId = request.CheckoutFormId, + CheckoutType = request.CheckoutType ?? CheckoutType.V1 }; } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index 119c3ef74..38c7f5c31 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -437,6 +437,8 @@ namespace BTCPayServer.Controllers.Greenfield DefaultLanguage = entity.DefaultLanguage, RedirectAutomatically = entity.RedirectAutomatically, RequiresRefundEmail = entity.RequiresRefundEmail, + CheckoutFormId = entity.CheckoutFormId, + CheckoutType = entity.CheckoutType, RedirectURL = entity.RedirectURLTemplate }, Receipt = entity.ReceiptOptions diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs index 18d464309..861221037 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs @@ -127,6 +127,8 @@ namespace BTCPayServer.Controllers.Greenfield //we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572) NetworkFeeMode = storeBlob.NetworkFeeMode, RequiresRefundEmail = storeBlob.RequiresRefundEmail, + CheckoutFormId = storeBlob.CheckoutFormId, + CheckoutType = storeBlob.CheckoutType, Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null), LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi, LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints, @@ -165,6 +167,8 @@ namespace BTCPayServer.Controllers.Greenfield blob.NetworkFeeMode = restModel.NetworkFeeMode; blob.DefaultCurrency = restModel.DefaultCurrency; blob.RequiresRefundEmail = restModel.RequiresRefundEmail; + blob.CheckoutFormId = restModel.CheckoutFormId; + blob.CheckoutType = restModel.CheckoutType; blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null); blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi; blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints; diff --git a/BTCPayServer/Controllers/UIInvoiceController.Testing.cs b/BTCPayServer/Controllers/UIInvoiceController.Testing.cs index b7b1638d3..bdd41d27f 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.Testing.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.Testing.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Data; @@ -24,34 +25,35 @@ namespace BTCPayServer.Controllers public string CryptoCode { get; set; } = "BTC"; } - [HttpPost] - [Route("i/{invoiceId}/test-payment")] + [HttpPost("i/{invoiceId}/test-payment")] [CheatModeRoute] public async Task TestPayment(string invoiceId, FakePaymentRequest request, [FromServices] Cheater cheater) { var invoice = await _InvoiceRepository.GetInvoice(invoiceId); var store = await _StoreRepository.FindStore(invoice.StoreId); - // TODO support altcoins, not just bitcoin - var network = _NetworkProvider.GetNetwork(request.CryptoCode); + // TODO support altcoins, not just bitcoin - and make it work for LN-only invoices + var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS"; + var cryptoCode = isSats ? "BTC" : request.CryptoCode; + var network = _NetworkProvider.GetNetwork(cryptoCode); var paymentMethodId = new [] {store.GetDefaultPaymentId()}.Concat(store.GetEnabledPaymentIds(_NetworkProvider)) - .FirstOrDefault(p => p!= null && p.CryptoCode == request.CryptoCode && p.PaymentType == PaymentTypes.BTCLike); + .FirstOrDefault(p => p != null && p.CryptoCode == cryptoCode && p.PaymentType == PaymentTypes.BTCLike); var bitcoinAddressString = invoice.GetPaymentMethod(paymentMethodId).GetPaymentMethodDetails().GetPaymentDestination(); var bitcoinAddressObj = BitcoinAddress.Create(bitcoinAddressString, network.NBitcoinNetwork); - var BtcAmount = request.Amount; + var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC); try { var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); var rate = paymentMethod.Rate; - var txid = cheater.CashCow.SendToAddress(bitcoinAddressObj, new Money(BtcAmount, MoneyUnit.BTC)).ToString(); + var txid = (await cheater.CashCow.SendToAddressAsync(bitcoinAddressObj, amount)).ToString(); // TODO The value of totalDue is wrong. How can we get the real total due? invoice.Price is only correct if this is the 2nd payment, not for a 3rd or 4th payment. var totalDue = invoice.Price; return Ok(new { Txid = txid, - AmountRemaining = (totalDue - (BtcAmount * rate)) / rate, + AmountRemaining = (totalDue - (amount.ToUnit(MoneyUnit.BTC) * rate)) / rate, SuccessMessage = "Created transaction " + txid }); } @@ -65,8 +67,7 @@ namespace BTCPayServer.Controllers } } - [HttpPost] - [Route("i/{invoiceId}/mine-blocks")] + [HttpPost("i/{invoiceId}/mine-blocks")] [CheatModeRoute] public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater) { @@ -96,8 +97,7 @@ namespace BTCPayServer.Controllers } } - [HttpPost] - [Route("i/{invoiceId}/expire")] + [HttpPost("i/{invoiceId}/expire")] [CheatModeRoute] public async Task TestExpireNow(string invoiceId, [FromServices] Cheater cheater) { diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 053a46b51..b6e234352 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -24,6 +24,7 @@ using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices.Export; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -603,23 +604,26 @@ namespace BTCPayServer.Controllers [HttpGet("i/{invoiceId}/{paymentMethodId}")] [HttpGet("invoice")] [AcceptMediaTypeConstraint("application/bitcoin-paymentrequest", false)] - [XFrameOptionsAttribute(null)] - [ReferrerPolicyAttribute("origin")] + [XFrameOptions(null)] + [ReferrerPolicy("origin")] public async Task Checkout(string? invoiceId, string? id = null, string? paymentMethodId = null, [FromQuery] string? view = null, [FromQuery] string? lang = null) { - //Keep compatibility with Bitpay - invoiceId = invoiceId ?? id; - // + // Keep compatibility with Bitpay + invoiceId ??= id; + if (invoiceId is null) return NotFound(); + var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang); if (model == null) return NotFound(); if (view == "modal") model.IsModal = true; - return View(nameof(Checkout), model); + + var viewName = model.CheckoutType == CheckoutType.V2 ? "CheckoutV2" : nameof(Checkout); + return View(viewName, model); } [HttpGet("invoice-noscript")] @@ -731,15 +735,18 @@ namespace BTCPayServer.Controllers var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true; var receiptUrl = receiptEnabled? _linkGenerator.GetUriByAction( - nameof(UIInvoiceController.InvoiceReceipt), + nameof(InvoiceReceipt), "UIInvoice", new {invoiceId}, Request.Scheme, Request.Host, Request.PathBase) : null; - + var model = new PaymentModel { +#if ALTCOINS + AltcoinsBuild = true, +#endif Activated = paymentMethodDetails.Activated, CryptoCode = network.CryptoCode, RootPath = Request.PathBase.Value.WithTrailingSlash(), @@ -748,6 +755,10 @@ namespace BTCPayServer.Controllers DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en", CustomCSSLink = storeBlob.CustomCSS, CustomLogoLink = storeBlob.CustomLogo, + LogoFileId = storeBlob.LogoFileId, + BrandColor = storeBlob.BrandColor, + CheckoutFormId = invoice.CheckoutFormId ?? storeBlob.CheckoutFormId, + CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType, HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)), BtcAddress = paymentMethodDetails.GetPaymentDestination(), @@ -783,12 +794,19 @@ namespace BTCPayServer.Controllers IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, StoreId = store.Id, AvailableCryptos = invoice.GetPaymentMethods() - .Where(i => i.Network != null) + .Where(i => i.Network != null && + // TODO: These cases and implementation need to be discussed + (storeBlob.CheckoutType == CheckoutType.V1 || + // Exclude LNURL for non-topup invoices + (invoice.IsUnsetTopUp() || i.GetId().PaymentType is not LNURLPayPaymentType)) && + // Exclude Lightning if OnChainWithLnInvoiceFallback is active + (!storeBlob.OnChainWithLnInvoiceFallback || i.GetId().PaymentType is not LightningPaymentType) + ) .Select(kv => { var availableCryptoPaymentMethodId = kv.GetId(); var availableCryptoHandler = _paymentMethodHandlerDictionary[availableCryptoPaymentMethodId]; - return new PaymentModel.AvailableCrypto() + return new PaymentModel.AvailableCrypto { PaymentMethodId = kv.GetId().ToString(), CryptoCode = kv.Network?.CryptoCode ?? kv.GetId().CryptoCode, @@ -907,6 +925,14 @@ namespace BTCPayServer.Controllers return Ok("{}"); } + [HttpPost("i/{invoiceId}/Form")] + [HttpPost("invoice/Form")] + public IActionResult UpdateForm(string invoiceId) + { + // TODO: Forms integration + return Ok(); + } + [HttpGet("/stores/{storeId}/invoices")] [HttpGet("invoices")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)] @@ -1053,10 +1079,12 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(UIHomeController.Index), "UIHome"); } + var storeBlob = HttpContext.GetStoreData()?.GetStoreBlob(); var vm = new CreateInvoiceModel { StoreId = model.StoreId, - Currency = HttpContext.GetStoreData()?.GetStoreBlob().DefaultCurrency, + Currency = storeBlob?.DefaultCurrency, + UseNewCheckout = storeBlob?.CheckoutType is CheckoutType.V2, AvailablePaymentMethods = GetPaymentMethodsSelectList() }; @@ -1069,8 +1097,11 @@ namespace BTCPayServer.Controllers [BitpayAPIConstraint(false)] public async Task CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken) { - model.AvailablePaymentMethods = GetPaymentMethodsSelectList(); var store = HttpContext.GetStoreData(); + var storeBlob = store.GetStoreBlob(); + model.UseNewCheckout = storeBlob.CheckoutType == CheckoutType.V2; + model.AvailablePaymentMethods = GetPaymentMethodsSelectList(); + if (!ModelState.IsValid) { return View(model); @@ -1089,18 +1120,17 @@ namespace BTCPayServer.Controllers try { - var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest() + var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest { Price = model.Amount, Currency = model.Currency, PosData = model.PosData, OrderId = model.OrderId, - //RedirectURL = redirect + "redirect", NotificationURL = model.NotificationUrl, ItemDesc = model.ItemDesc, FullNotifications = true, BuyerEmail = model.BuyerEmail, - SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency() + SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency { Enabled = true }), @@ -1108,8 +1138,11 @@ namespace BTCPayServer.Controllers NotificationEmail = model.NotificationEmail, ExtendedNotifications = model.NotificationEmail != null, RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore - ? store.GetStoreBlob().RequiresRefundEmail - : model.RequiresRefundEmail == RequiresRefundEmail.On + ? storeBlob.RequiresRefundEmail + : model.RequiresRefundEmail == RequiresRefundEmail.On, + CheckoutFormId = model.CheckoutFormId == GenericFormOption.InheritFromStore.ToString() + ? storeBlob.CheckoutFormId + : model.CheckoutFormId }, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken); TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!"; diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index 569c86d24..f4ef3257d 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -138,6 +138,7 @@ namespace BTCPayServer.Controllers entity.RedirectAutomatically = invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically); entity.RequiresRefundEmail = invoice.RequiresRefundEmail; + entity.CheckoutFormId = invoice.CheckoutFormId; entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy); IPaymentFilter? excludeFilter = null; @@ -193,6 +194,8 @@ namespace BTCPayServer.Controllers entity.DefaultLanguage = invoice.Checkout.DefaultLanguage; entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod; entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically; + entity.CheckoutFormId = invoice.Checkout.CheckoutFormId; + entity.CheckoutType = invoice.Checkout.CheckoutType; entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail; IPaymentFilter? excludeFilter = null; if (invoice.Checkout.PaymentMethods != null) @@ -278,7 +281,6 @@ namespace BTCPayServer.Controllers if (!noNeedForMethods) { - // This loop ends with .ToList so we are querying all payment methods at once // instead of sequentially to improve response time var x1 = store.GetSupportedPaymentMethods(_NetworkProvider) diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index 8677e0e10..b41c8078d 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -365,20 +365,11 @@ namespace BTCPayServer.Controllers .Where(s => s.PaymentId.PaymentType != PaymentTypes.LNURLPay) .Select(method => { - var existing = - storeBlob.PaymentMethodCriteria.SingleOrDefault(criteria => + var existing = storeBlob.PaymentMethodCriteria.SingleOrDefault(criteria => criteria.PaymentMethod == method.PaymentId); - if (existing is null) - { - return new PaymentMethodCriteriaViewModel() - { - PaymentMethod = method.PaymentId.ToString(), - Value = "" - }; - } - else - { - return new PaymentMethodCriteriaViewModel() + return existing is null + ? new PaymentMethodCriteriaViewModel { PaymentMethod = method.PaymentId.ToString(), Value = "" } + : new PaymentMethodCriteriaViewModel { PaymentMethod = existing.PaymentMethod.ToString(), Type = existing.Above @@ -386,9 +377,11 @@ namespace BTCPayServer.Controllers : PaymentMethodCriteriaViewModel.CriteriaType.LessThan, Value = existing.Value?.ToString() ?? "" }; - } }).ToList(); + vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2; + vm.CheckoutFormId = storeBlob.CheckoutFormId; + vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods; vm.RedirectAutomatically = storeBlob.RedirectAutomatically; @@ -504,6 +497,14 @@ namespace BTCPayServer.Controllers PaymentMethod = paymentMethodId }); } + + blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1; + if (blob.CheckoutType == Client.Models.CheckoutType.V2) + { + blob.CheckoutFormId = model.CheckoutFormId; + blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback; + } + blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.LazyPaymentMethods = model.LazyPaymentMethods; blob.RedirectAutomatically = model.RedirectAutomatically; diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 724f9417a..77b9dc3dc 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -36,6 +36,9 @@ namespace BTCPayServer.Data [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] public NetworkFeeMode NetworkFeeMode { get; set; } + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public CheckoutType CheckoutType { get; set; } + public string CheckoutFormId { get; set; } public bool RequiresRefundEmail { get; set; } public bool LightningAmountInSatoshi { get; set; } public bool LightningPrivateRouteHints { get; set; } diff --git a/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs b/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs index e739bd4fd..858a10d5d 100644 --- a/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs +++ b/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs @@ -81,6 +81,9 @@ namespace BTCPayServer.Models public bool? RedirectAutomatically { get; set; } [JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)] public bool? RequiresRefundEmail { get; set; } + + [JsonProperty(PropertyName = "checkoutFormId", DefaultValueHandling = DefaultValueHandling.Ignore)] + public string CheckoutFormId { get; set; } //Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies [JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)] diff --git a/BTCPayServer/Models/InvoiceResponse.cs b/BTCPayServer/Models/InvoiceResponse.cs index 24f004c3f..570ec07c0 100644 --- a/BTCPayServer/Models/InvoiceResponse.cs +++ b/BTCPayServer/Models/InvoiceResponse.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using BTCPayServer.Client.Models; using BTCPayServer.Services.Invoices; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; namespace BTCPayServer.Models @@ -266,6 +268,11 @@ namespace BTCPayServer.Models public Dictionary PaymentCodes { get; set; } [JsonProperty("buyer")] public JObject Buyer { get; set; } + + [JsonProperty("checkoutFormId")] + public string CheckoutFormId { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public CheckoutType? CheckoutType { get; set; } } public class Flags { diff --git a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs index e568104a2..3a82c08c4 100644 --- a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs +++ b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs @@ -1,7 +1,7 @@ -using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Linq; using BTCPayServer.Services.Apps; using BTCPayServer.Validation; using Microsoft.AspNetCore.Mvc.Rendering; @@ -88,5 +88,10 @@ namespace BTCPayServer.Models.InvoicingModels { get; set; } + + [Display(Name = "Request customer data on checkout")] + public string CheckoutFormId { get; set; } + + public bool UseNewCheckout { get; set; } } } diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index bee5469fe..ac748d119 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using BTCPayServer.Client.Models; namespace BTCPayServer.Models.InvoicingModels { @@ -23,9 +24,11 @@ namespace BTCPayServer.Models.InvoicingModels } public string CustomCSSLink { get; set; } public string CustomLogoLink { get; set; } + public string LogoFileId { get; set; } + public string BrandColor { get; set; } public string HtmlTitle { get; set; } public string DefaultLang { get; set; } - public List AvailableCryptos { get; set; } = new List(); + public List AvailableCryptos { get; set; } = new (); public bool IsModal { get; set; } public bool IsUnsetTopUp { get; set; } public string CryptoCode { get; set; } @@ -69,5 +72,8 @@ namespace BTCPayServer.Models.InvoicingModels public bool Activated { get; set; } public string InvoiceCurrency { get; set; } public string ReceiptLink { get; set; } + public string CheckoutFormId { get; set; } + public bool AltcoinsBuild { get; set; } + public CheckoutType CheckoutType { get; set; } } } diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs index 0e49d72a0..de1b20448 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs @@ -5,6 +5,7 @@ using System.Linq; using BTCPayServer.Services; using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json.Linq; +using YamlDotNet.Core.Tokens; namespace BTCPayServer.Models.StoreViewModels { @@ -20,11 +21,21 @@ namespace BTCPayServer.Models.StoreViewModels Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); DefaultLang = chosen.Value; } + public SelectList Languages { get; set; } + [Display(Name = "Request customer data on checkout")] + public string CheckoutFormId { get; set; } + + [Display(Name = "Include Lightning invoice fallback to on-chain BIP21 payment URL")] + public bool OnChainWithLnInvoiceFallback { get; set; } + [Display(Name = "Default payment method on checkout")] public string DefaultPaymentMethod { get; set; } + [Display(Name = "Use the new checkout")] + public bool UseNewCheckout { get; set; } + [Display(Name = "Requires a refund email")] public bool RequiresRefundEmail { get; set; } diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index 0e86f2f3d..0138607a8 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -221,6 +221,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers PosData = string.IsNullOrEmpty(posData) ? null : posData, RedirectAutomatically = settings.RedirectAutomatically, SupportedTransactionCurrencies = paymentMethods, + CheckoutFormId = store.GetStoreBlob().CheckoutFormId, RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore ? store.GetStoreBlob().RequiresRefundEmail : requiresRefundEmail == RequiresRefundEmail.On, @@ -252,10 +253,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers if (app == null) return NotFound(); + var storeBlob = GetCurrentStore().GetStoreBlob(); var settings = app.GetSettings(); settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView; settings.EnableShoppingCart = false; - + var vm = new UpdatePointOfSaleViewModel { Id = appId, @@ -281,7 +283,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers RedirectUrl = settings.RedirectUrl, SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}", RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "", - RequiresRefundEmail = settings.RequiresRefundEmail + RequiresRefundEmail = settings.RequiresRefundEmail, + CheckoutFormId = settings.CheckoutFormId, + UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2 }; if (HttpContext?.Request != null) { @@ -348,8 +352,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers return View("PointOfSale/UpdatePointOfSale", vm); } - app.Name = vm.AppName; - app.SetSettings(new PointOfSaleSettings + var storeBlob = GetCurrentStore().GetStoreBlob(); + var settings = new PointOfSaleSettings { Title = vm.Title, DefaultView = vm.DefaultView, @@ -367,9 +371,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers RedirectUrl = vm.RedirectUrl, Description = vm.Description, EmbeddedCSS = vm.EmbeddedCSS, - RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically), - RequiresRefundEmail = vm.RequiresRefundEmail, - }); + RedirectAutomatically = + string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically), + RequiresRefundEmail = vm.RequiresRefundEmail + }; + + if (storeBlob.CheckoutType == Client.Models.CheckoutType.V2) + { + settings.CheckoutFormId = vm.CheckoutFormId == GenericFormOption.InheritFromStore.ToString() + ? storeBlob.CheckoutFormId + : vm.CheckoutFormId; + } + + app.Name = vm.AppName; + app.SetSettings(settings); await _appService.UpdateOrCreateApp(app); TempData[WellKnownTempData.SuccessMessage] = "App updated"; return RedirectToAction(nameof(UpdatePointOfSale), new { appId }); @@ -397,6 +412,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers } return currency.Trim().ToUpperInvariant(); } + + private StoreData GetCurrentStore() => HttpContext.GetStoreData(); private AppData GetCurrentApp() => HttpContext.GetAppData(); } diff --git a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs index 5e163b8ce..d643ed44f 100644 --- a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs +++ b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Stores; using BTCPayServer.Validation; using Microsoft.AspNetCore.Mvc.Rendering; @@ -97,7 +98,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Models [Display(Name = "Custom CSS Code")] public string EmbeddedCSS { get; set; } public string Description { get; set; } + [Display(Name = "Require refund email on checkout")] public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore; + + [Display(Name = "Request customer data on checkout")] + public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString(); + + public bool UseNewCheckout { get; set; } } } diff --git a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs index 83e7a5fe1..cb5458fe0 100644 --- a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs +++ b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs @@ -1,3 +1,6 @@ +using BTCPayServer.Client.Models; +using BTCPayServer.Services.Stores; + namespace BTCPayServer.Services.Apps { public class PointOfSaleSettings @@ -55,6 +58,8 @@ namespace BTCPayServer.Services.Apps public bool EnableTips { get; set; } public RequiresRefundEmail RequiresRefundEmail { get; set; } + public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString(); + public const string BUTTON_TEXT_DEF = "Buy for {0}"; public string ButtonText { get; set; } = BUTTON_TEXT_DEF; public const string CUSTOM_BUTTON_TEXT_DEF = "Pay"; @@ -72,5 +77,6 @@ namespace BTCPayServer.Services.Apps public string NotificationUrl { get; set; } public string RedirectUrl { get; set; } public bool? RedirectAutomatically { get; set; } - } + public CheckoutType CheckoutType { get; internal set; } + } } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index c406ee57e..e6dc42d97 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -446,6 +446,11 @@ namespace BTCPayServer.Services.Invoices [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } + [JsonProperty("checkoutFormId")] + public string CheckoutFormId { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public CheckoutType? CheckoutType { get; set; } + public bool IsExpired() { return DateTimeOffset.UtcNow > ExpirationTime; @@ -573,6 +578,8 @@ namespace BTCPayServer.Services.Invoices dto.TaxIncluded = Metadata.TaxIncluded ?? 0m; dto.Price = Price; dto.Currency = Currency; + dto.CheckoutFormId = CheckoutFormId; + dto.CheckoutType = CheckoutType; dto.Buyer = new JObject(); dto.Buyer.Add(new JProperty("name", Metadata.BuyerName)); dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1)); diff --git a/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs b/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs new file mode 100644 index 000000000..92c42967d --- /dev/null +++ b/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using BTCPayServer.Data; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Services.Stores; + +public enum GenericFormOption +{ + [Display(Name = "Inherit from store settings")] + InheritFromStore, + + [Display(Name = "Do not request any information")] + None, + + [Display(Name = "Request email address only")] + Email, + + [Display(Name = "Request shipping address")] + Address +} + +public static class CheckoutFormSelectList +{ + public static SelectList ForStore(StoreData store, string selectedFormId, bool isStoreEntity) + { + var choices = new List(); + + if (isStoreEntity) + { + var blob = store.GetStoreBlob(); + var inherit = GenericOptionItem(GenericFormOption.InheritFromStore); + inherit.Text += Enum.TryParse(blob.CheckoutFormId, out var item) + ? $" ({DisplayName(item)})" + : $" ({blob.CheckoutFormId})"; + + choices.Add(inherit); + } + + choices.Add(GenericOptionItem(GenericFormOption.None)); + choices.Add(GenericOptionItem(GenericFormOption.Email)); + choices.Add(GenericOptionItem(GenericFormOption.Address)); + + var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId); + return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value); + } + + private static string DisplayName(GenericFormOption opt) => + typeof(GenericFormOption).DisplayName(opt.ToString()); + + private static SelectListItem GenericOptionItem(GenericFormOption opt) => + new() { Text = DisplayName(opt), Value = opt.ToString() }; +} diff --git a/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml new file mode 100644 index 000000000..96cd4516a --- /dev/null +++ b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml @@ -0,0 +1,29 @@ +@using BTCPayServer.BIP78.Sender +@model BTCPayServer.Models.InvoicingModels.PaymentModel + + + + diff --git a/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml b/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml new file mode 100644 index 000000000..4260cf14d --- /dev/null +++ b/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml @@ -0,0 +1,23 @@ +@model BTCPayServer.Models.InvoicingModels.PaymentModel + + + + diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml index ce67235fc..aeec81dd8 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml @@ -2,9 +2,13 @@ @using BTCPayServer.Abstractions.Models @using BTCPayServer.Views.Apps @using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Services.Stores @model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel @{ ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id); + + var store = ViewContext.HttpContext.GetStoreData(); + var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true); }
@@ -72,9 +76,7 @@ -
- Choose the point of sale style for your customers. -
+

Choose the point of sale style for your customers.

@@ -82,9 +84,18 @@
- - - + @if (Model.UseNewCheckout) + { + + + + } + else + { + + + + }

Discounts

diff --git a/BTCPayServer/Views/UIInvoice/Checkout-Cheating.cshtml b/BTCPayServer/Views/UIInvoice/Checkout-Cheating.cshtml new file mode 100644 index 000000000..38d5e023f --- /dev/null +++ b/BTCPayServer/Views/UIInvoice/Checkout-Cheating.cshtml @@ -0,0 +1,83 @@ +@model PaymentModel + +
+

{{ successMessage }}

+

{{ errorMessage }}

+ + + +
+
+ +
@Model.CryptoCode
+
+ +
+ {{$t("This is the same as running bitcoin-cli.sh sendtoaddress xxx")}} + +
+ + +
+
+ +
{{$t("Blocks")}}
+
+ +
+
+
+ +
+
+ + diff --git a/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml new file mode 100644 index 000000000..38f5bd8de --- /dev/null +++ b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml @@ -0,0 +1,545 @@ +@inject LanguageService LangService +@inject BTCPayServerEnvironment Env +@inject IFileService FileService +@inject ThemeSettings Theme +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@using BTCPayServer.Services +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Components.ThemeSwitch +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model PaymentModel +@{ + Layout = null; + ViewData["Title"] = Model.HtmlTitle; + + var paymentMethodCount = Model.AvailableCryptos.Count; + var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId) + ? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId) + : Model.CustomLogoLink; +} +@functions { + private string PaymentMethodName(PaymentModel.AvailableCrypto pm) + { + return Model.AltcoinsBuild + ? $"{pm.PaymentMethodName} {pm.CryptoCode}" + : pm.PaymentMethodName.Replace("Bitcoin (", "").Replace(")", "").Replace("Lightning ", ""); + } +} + + + + + + + + @if (!string.IsNullOrEmpty(Model.CustomCSSLink)) + { + + } + @if (Model.IsModal) + { + + } + @if (!string.IsNullOrEmpty(Model.BrandColor)) + { + + } + + +
+
+ @if (!string.IsNullOrEmpty(logoUrl)) + { + + } +

@Model.StoreName

+
+
+ +
+ +
+
+ + + +

{{$t("Invoice expired")}}

+
+
+
{{$t("Invoice ID")}}
+
{{srvModel.invoiceId}}
+
+
+
{{$t("Order ID")}}
+
{{srvModel.orderId}}
+
+
+

+

{{$t("InvoiceExpired_Body_2")}}

+

{{$t("InvoiceExpired_Body_3")}}

+
+ +
+
+
+
+
+
{{$t("Please fill out the following")}}
+
+ {{$t("Invoice will expire in")}} {{timerText}} + + + +
+ + +
+
+ +
+
+
+
+
@Model.ItemDesc
+ @if (Model.IsUnsetTopUp) + { +

{{$t("Any amount")}}

+ } + else + { +

@Model.BtcDue @Model.CryptoCode

+

@Model.BtcDue @Model.CryptoCode

+
@Model.OrderAmountFiat
+
+ {{$t("Invoice will expire in")}} {{timerText}} + + + +
+
+ + {{$t("NotPaid_ExtraTransaction")}} +
+ +
+
+
+
{{$t("Total Price")}}
+
{{srvModel.orderAmount}} {{ srvModel.cryptoCode }}
+
+
+
{{$t("Exchange Rate")}}
+
+ + +
+
+
+
{{$t("Recommended Fee")}}
+
{{$t("Feerate", { feeRate: srvModel.feeRate })}}
+
+
+
{{$t("Network Cost")}}
+
+ + {{ srvModel.networkFee }} {{ srvModel.cryptoCode }} +
+
+
+
{{$t("Amount Paid")}}
+
{{srvModel.btcPaid }} {{ srvModel.cryptoCode }}
+
+
+
{{$t("Amount Due")}}
+
{{srvModel.btcDue}} {{ srvModel.cryptoCode }}
+
+
+
+ } +
+ @if (paymentMethodCount > 1) + { +
{{$t("Pay with")}}
+
+ @foreach (var crypto in Model.AvailableCryptos) + { + + @PaymentMethodName(crypto) + + } +
+ } + else + { +
+ {{$t("Pay with")}} + @PaymentMethodName(Model.AvailableCryptos.First()) +
+ } +
+ +
+
+ @if (Env.CheatMode) + { + + } +
+ + +
+ Powered by BTCPay Server +
+ @if (!Theme.CustomTheme) + { + + } +
+
+ + + + + + + + + + @if (Env.CheatMode) + { + + } + + @foreach (var paymentMethodHandler in PaymentMethodHandlerDictionary + .Select(handler => handler.GetCheckoutUISettings()) + .Where(settings => settings != null) + .DistinctBy(pm => pm.ExtensionPartial)) + { + + } + @await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-end", model = Model }) + + diff --git a/BTCPayServer/Views/UIInvoice/CreateInvoice.cshtml b/BTCPayServer/Views/UIInvoice/CreateInvoice.cshtml index ecb47d08d..b40d62dfa 100644 --- a/BTCPayServer/Views/UIInvoice/CreateInvoice.cshtml +++ b/BTCPayServer/Views/UIInvoice/CreateInvoice.cshtml @@ -1,7 +1,11 @@ @model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel @using BTCPayServer.Services.Apps +@using BTCPayServer.Services.Stores @{ ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice"); + + var store = ViewContext.HttpContext.GetStoreData(); + var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true); } @section PageFootContent { @@ -90,9 +94,18 @@
- - - + @if (Model.UseNewCheckout) + { + + + + } + else + { + + + + }

Additional Options

diff --git a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml index d5263fbab..ec38d1232 100644 --- a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml +++ b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml @@ -1,8 +1,28 @@ @using BTCPayServer.Payments +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Services.Stores @model CheckoutAppearanceViewModel @{ Layout = "../Shared/_NavLayout.cshtml"; ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id); + + var store = ViewContext.HttpContext.GetStoreData(); + var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, false); +} + +@section PageFootContent { + + }
@@ -43,6 +63,7 @@
} +
@@ -56,7 +77,35 @@
-

Public receipt

+

+ New checkout + Experimental +

+
+ +
+ + +
+ Since v1.7.0 a new version of the checkout is available.
+ We are still collecting feedback and offer this as an opt-in feature. +
+
+
+ +
+
+ + + +
+
+ + +
+
+ +

Public receipt

@@ -75,7 +124,7 @@
-

Detects the language of the customer's browser with 99.9% accuracy.

+

Detects the language of the customer's browser with 99.9% accuracy.

@@ -116,16 +165,3 @@
- -@section PageFootContent { - - -} diff --git a/BTCPayServer/wwwroot/checkout-v2/checkout.css b/BTCPayServer/wwwroot/checkout-v2/checkout.css new file mode 100644 index 000000000..557269f06 --- /dev/null +++ b/BTCPayServer/wwwroot/checkout-v2/checkout.css @@ -0,0 +1,152 @@ +:root { + --logo-size: 3rem; + --navbutton-size: .8rem; + --qr-size: 256px; + --section-padding: var(--btcpay-space-l); +} +body { + padding: var(--btcpay-space-m); +} +header, +footer { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--btcpay-space-l) var(--btcpay-space-s); + gap: var(--btcpay-space-m); +} +main { + position: relative; + border-radius: var(--btcpay-border-radius-l); + background-color: var(--btcpay-bg-tile); +} +nav { + position: absolute; + top: var(--btcpay-space-m); + left: var(--btcpay-space-m); + right: var(--btcpay-space-m); +} +nav button { + position: absolute; + top: 0; + border: none; + background: none; + color: var(--btcpay-body-text-muted); +} +nav button:hover { + color: var(--btcpay-body-text-hover); +} +nav button#back { + left: 0; +} +nav button#close { + right: 0; +} +nav button .icon { + width: var(--navbutton-size); + height: var(--navbutton-size); +} +section { + padding: var(--section-padding); +} +section h4 { + margin-bottom: var(--btcpay-space-l); + font-weight: var(--btcpay-font-weight-semibold); + text-align: center; +} +section h5, +section h6 { + margin-bottom: 1.5rem; + font-weight: var(--btcpay-font-weight-semibold); + text-align: center; +} +section .top { + flex: 1; +} +section .buttons { + display: flex; + flex-direction: column; + gap: var(--btcpay-space-m); +} +section dl { + margin-bottom: 1.5rem; +} +section dl > div { + display: flex; + justify-content: space-between; +} +section dl > div dt, +section dl > div dd { + margin: 0; + padding: var(--btcpay-space-xs) 0; + font-weight: var(--btcpay-font-weight-normal); +} +section dl > div dt { + text-align: left; +} +section dl > div dd { + text-align: right; +} +.logo { + height: var(--logo-size); +} +.logo--square { + width: var(--logo-size); + border-radius: 50%; +} +.wrap { + max-width: 400px; + margin: 0 auto; +} +.timer { + display: flex; + align-items: center; + justify-content: center; + margin: var(--btcpay-space-m) calc(var(--section-padding) * -1) var(--btcpay-space-s); + padding: var(--btcpay-space-s) var(--section-padding); + background-color: var(--btcpay-body-bg-medium); + text-align: center; +} +.payment-box { + max-width: 300px; + min-width: var(--qr-size); + margin: 0 auto; + text-align: center; +} +.payment-box .qr-text { + display: block; + color: var(--btcpay-light-text); +} +.payment-box .qr-container { + min-height: var(--qr-size); +} +.payment-box svg { + width: 100%; +} +.payment-box [data-clipboard] { + cursor: copy; +} +#payment .btcpay-pills .btcpay-pill { + padding: var(--btcpay-space-xs) var(--btcpay-space-m); +} +#form > form, +#result > div { + display: flex; + flex-direction: column; +} +#result .top .icon { + display: block; + width: 3rem; + height: 3rem; + margin: .5rem auto 1.5rem; +} +#PaymentDetails dl { + margin: 0; +} +#PaymentDetailsButton { + margin: 0 auto; + padding: var(--btcpay-space-s); +} +#PaymentDetailsButton .icon { + margin-left: -1rem; /* Adjust for visual center */ +} diff --git a/BTCPayServer/wwwroot/checkout-v2/checkout.js b/BTCPayServer/wwwroot/checkout-v2/checkout.js new file mode 100644 index 000000000..e9c46f530 --- /dev/null +++ b/BTCPayServer/wwwroot/checkout-v2/checkout.js @@ -0,0 +1,60 @@ +const urlParams = {}; +(window.onpopstate = function () { + let match, + pl = /\+/g, // Regex for replacing addition symbol with a space + search = /([^&=]+)=?([^&]*)/g, + decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }, + query = window.location.search.substring(1); + + while (match = search.exec(query)) { + urlParams[decode(match[1])] = decode(match[2]); + } +})(); + +document.addEventListener('DOMContentLoaded', () => { + // Theme Switch + delegate('click', '.btcpay-theme-switch', e => { + e.preventDefault() + const current = document.documentElement.getAttribute(THEME_ATTR) || COLOR_MODES[0] + const mode = current === COLOR_MODES[0] ? COLOR_MODES[1] : COLOR_MODES[0] + setColorMode(mode) + e.target.closest('.btcpay-theme-switch').blur() + }) +}); + +Vue.directive('collapsible', { + bind: function (el) { + el.transitionDuration = 350; + }, + update: function (el, binding) { + if (binding.oldValue !== binding.value){ + if (binding.value) { + setTimeout(function () { + el.classList.remove('collapse'); + const height = window.getComputedStyle(el).height; + el.classList.add('collapsing'); + el.offsetHeight; + el.style.height = height; + setTimeout(() => { + el.classList.remove('collapsing'); + el.classList.add('collapse'); + el.style.height = null; + el.classList.add('show'); + }, el.transitionDuration) + }, 0); + } + else { + el.style.height = window.getComputedStyle(el).height; + el.classList.remove('collapse'); + el.classList.remove('show'); + el.offsetHeight; + el.style.height = null; + el.classList.add('collapsing'); + setTimeout(() => { + el.classList.add('collapse'); + el.classList.remove("collapsing"); + }, el.transitionDuration) + } + } + } +}); diff --git a/BTCPayServer/wwwroot/checkout/js/querystring.js b/BTCPayServer/wwwroot/checkout/js/querystring.js index de426446f..62ae92b5f 100644 --- a/BTCPayServer/wwwroot/checkout/js/querystring.js +++ b/BTCPayServer/wwwroot/checkout/js/querystring.js @@ -1,12 +1,11 @@ -var urlParams; +const urlParams = {}; (window.onpopstate = function () { - var match, + let match, pl = /\+/g, // Regex for replacing addition symbol with a space search = /([^&=]+)=?([^&]*)/g, decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }, query = window.location.search.substring(1); - - urlParams = {}; + while (match = search.exec(query)) { urlParams[decode(match[1])] = decode(match[2]); } diff --git a/BTCPayServer/wwwroot/img/icon-sprite.svg b/BTCPayServer/wwwroot/img/icon-sprite.svg index 8079d7fbb..e758119ca 100644 --- a/BTCPayServer/wwwroot/img/icon-sprite.svg +++ b/BTCPayServer/wwwroot/img/icon-sprite.svg @@ -4,6 +4,7 @@ + @@ -44,4 +45,7 @@ + + + diff --git a/BTCPayServer/wwwroot/js/copy-to-clipboard.js b/BTCPayServer/wwwroot/js/copy-to-clipboard.js index ab72d7dc0..c4f7a9328 100644 --- a/BTCPayServer/wwwroot/js/copy-to-clipboard.js +++ b/BTCPayServer/wwwroot/js/copy-to-clipboard.js @@ -8,7 +8,9 @@ const confirmCopy = (el, message) => { window.copyToClipboard = function (e, data) { e.preventDefault(); const item = e.target.closest('[data-clipboard]') || e.target.closest('[data-clipboard-target]') || e.target; - const confirm = item.querySelector('[data-clipboard-confirm]') || item; + const confirm = item.dataset.clipboardConfirmElement + ? document.getElementById(item.dataset.clipboardConfirmElement) || item + : item.querySelector('[data-clipboard-confirm]') || item; const message = confirm.getAttribute('data-clipboard-confirm') || 'Copied ✔'; if (!confirm.dataset.clipboardInitial) { confirm.dataset.clipboardInitial = confirm.innerHTML; diff --git a/BTCPayServer/wwwroot/locales/en.json b/BTCPayServer/wwwroot/locales/en.json index 97a63968a..ddb16bd55 100644 --- a/BTCPayServer/wwwroot/locales/en.json +++ b/BTCPayServer/wwwroot/locales/en.json @@ -47,7 +47,17 @@ "Pay with CoinSwitch": "Pay with CoinSwitch", "Pay with Changelly": "Pay with Changelly", "Close": "Close", - "NotPaid_ExtraTransaction": "The invoice hasn't been paid in full. Please send another transaction to cover amount Due.", + "NotPaid_ExtraTransaction": "The invoice hasn't been paid in full. Please send another transaction to cover amount due.", "Recommended_Fee": "Recommended fee: {{feeRate}} sat/byte", - "View receipt": "View receipt" -} \ No newline at end of file + "View receipt": "View receipt", + "View Details": "View Details", + "QR_Text": "Scan the QR code, or tap to copy the address.", + "Total Price": "Total Price", + "Exchange Rate": "Exchange Rate", + "Recommended Fee": "Recommended Fee", + "Feerate": "{{feeRate}} sat/byte", + "Amount Paid": "Amount Paid", + "Amount Due": "Amount Due", + "Pay in wallet": "Pay in wallet", + "Invoice paid": "Invoice paid" +} diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index ab8dd0cc2..59996183f 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -84,7 +84,7 @@ a.unobtrusive-link { box-shadow: none; } -[data-bs-toggle="collapse"] svg.icon { +[aria-expanded] > svg.icon-caret-down { flex-shrink: 0; width: 24px; height: 24px; @@ -92,7 +92,7 @@ a.unobtrusive-link { transition: transform 0.2s ease-in-out; } -[data-bs-toggle="collapse"][aria-expanded="true"] svg.icon { +[aria-expanded="true"] > svg.icon-caret-down { transform: rotate(-180deg); } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json index 07e8c377c..c199b80b5 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json @@ -358,6 +358,14 @@ "type": "boolean", "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", "nullable": true + }, + "checkoutType": { + "$ref": "#/components/schemas/CheckoutType" + }, + "checkoutFormId": { + "type": "string", + "description": "Form ID to request customer data, in case the new checkout is used", + "nullable": true } } } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json index 9caef6856..5a2fb712c 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json @@ -1074,6 +1074,25 @@ "nullable": true, "description": "Invoice will require user to provide a refund email if this option is set to `true`. Has no effect if `buyerEmail` metadata is set as there is no email to collect in this case." }, + "checkoutType": { + "type": "string", + "description": "`\"V1\"`: The original checkout form \n`\"V2\"`: The new experimental checkout form. \nIf `null` or unspecified, the store's settings will be used.", + "nullable": true, + "default": null, + "x-enumNames": [ + "V1", + "V2" + ], + "enum": [ + "V1", + "V2" + ] + }, + "checkoutFormId": { + "type": "string", + "description": "Form ID to request customer data, in case the new checkout is used", + "nullable": true + }, "defaultLanguage": { "type": "string", "nullable": true, diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json index a459f7470..1932a3a9c 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json @@ -79,19 +79,33 @@ "LowMediumSpeed" ] }, + "CheckoutType": { + "type": "string", + "description": "`\"V1\"`: The original checkout form \n`\"V2\"`: The new experimental checkout form", + "nullable": true, + "default": "V1", + "x-enumNames": [ + "V1", + "V2" + ], + "enum": [ + "V1", + "V2" + ] + }, "TimeSpan": { "type": "number", "format": "int32", "example": 90 }, "TimeSpanSeconds": { - "allOf": [ {"$ref": "#/components/schemas/TimeSpan"}], + "allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ], "format": "seconds", "description": "A span of times in seconds" }, "TimeSpanMinutes": { - "allOf": [ {"$ref": "#/components/schemas/TimeSpan"}], + "allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ], "format": "minutes", "description": "A span of times in minutes" } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json index 4670e9837..6cf6662a7 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json @@ -341,6 +341,14 @@ "default": false, "description": "If true, the checkout page will ask to enter an email address before accessing payment information." }, + "checkoutType": { + "$ref": "#/components/schemas/CheckoutType" + }, + "checkoutFormId": { + "type": "string", + "description": "Form ID to request customer data, in case the new checkout is used", + "nullable": true + }, "receipt": { "nullable": true, "$ref": "#/components/schemas/ReceiptOptions", diff --git a/BTCPayServer/wwwroot/vendor/i18next/vue-i18next.js b/BTCPayServer/wwwroot/vendor/i18next/vue-i18next.js index dbd53027e..ecaff4ba0 100644 --- a/BTCPayServer/wwwroot/vendor/i18next/vue-i18next.js +++ b/BTCPayServer/wwwroot/vendor/i18next/vue-i18next.js @@ -1,2 +1 @@ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("VueI18next",[],t):"object"==typeof exports?exports.VueI18next=t():e.VueI18next=t()}(this,function(){return function(e){function t(i){if(n[i])return n[i].exports;var o=n[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,i){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:i})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="/dist/",t(t.s=2)}([function(e,t,n){"use strict";function i(e){i.installed||(i.installed=!0,t.Vue=u=e,u.mixin({computed:{$t:function(){var e=this;return function(t,n){return e.$i18n.t(t,n,e.$i18n.i18nLoadedAt)}}},beforeCreate:function(){var e=this.$options;e.i18n?this.$i18n=e.i18n:e.parent&&e.parent.$i18n&&(this.$i18n=e.parent.$i18n)}}),u.component(r.default.name,r.default))}Object.defineProperty(t,"__esModule",{value:!0}),t.Vue=void 0,t.install=i;var o=n(1),r=function(e){return e&&e.__esModule?e:{default:e}}(o),u=t.Vue=void 0},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default={name:"i18next",functional:!0,props:{tag:{type:String,default:"span"},path:{type:String,required:!0}},render:function(e,t){var n=t.props,i=t.data,o=t.children,r=t.parent,u=r.$i18n;if(!u)return o;var a=n.path,s=u.i18next.services.interpolator.regexp,f=u.t(a,{interpolation:{prefix:"#$?",suffix:"?$#"}}),d=[],c={};return o.forEach(function(e){e.data&&e.data.attrs&&e.data.attrs.tkey&&(c[e.data.attrs.tkey]=e)}),f.split(s).reduce(function(e,t,n){var i=void 0;if(n%2==0){if(0===t.length)return e;i=t}else i=o[parseInt(t,10)];return e.push(i),e},d),e(n.tag,i,d)}},e.exports=t.default},function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},r=function(){function e(e,t){for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{};i(this,e);var o=n.bindI18n,r=void 0===o?"languageChanged loaded":o,u=n.bindStore,a=void 0===u?"added removed":u;this._vm=null,this.i18next=t,this.onI18nChanged=this.onI18nChanged.bind(this),r&&this.i18next.on(r,this.onI18nChanged),a&&this.i18next.store&&this.i18next.store.on(a,this.onI18nChanged),this.resetVM({i18nLoadedAt:new Date})}return r(e,[{key:"resetVM",value:function(e){var t=this._vm,n=u.Vue.config.silent;u.Vue.config.silent=!0,this._vm=new u.Vue({data:e}),u.Vue.config.silent=n,t&&u.Vue.nextTick(function(){return t.$destroy()})}},{key:"t",value:function(e,t){return this.i18next.t(e,t)}},{key:"onI18nChanged",value:function(){this.i18nLoadedAt=new Date}},{key:"i18nLoadedAt",get:function(){return this._vm.$data.i18nLoadedAt},set:function(e){this._vm.$set(this._vm,"i18nLoadedAt",e)}}]),e}();t.default=a,a.install=u.install,a.version="0.4.0",("undefined"==typeof window?"undefined":o(window))&&window.Vue&&window.Vue.use(a),e.exports=t.default}])}); -//# sourceMappingURL=vue-i18next.js.map \ No newline at end of file