diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs index e5afff7e74..808db64747 100644 --- a/BTCPayServer.Client/Models/InvoiceData.cs +++ b/BTCPayServer.Client/Models/InvoiceData.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using BTCPayServer.Client.JsonConverters; using BTCPayServer.JsonConverters; using Newtonsoft.Json; @@ -19,6 +20,48 @@ public class InvoiceDataBase public string Currency { get; set; } public JObject Metadata { get; set; } public CheckoutOptions Checkout { get; set; } = new CheckoutOptions(); + public ReceiptOptions Receipt { get; set; } = new ReceiptOptions(); + + public class ReceiptOptions + { + public bool? Enabled { get; set; } + public bool? ShowQR { get; set; } + public bool? ShowPayments { get; set; } + [JsonExtensionData] + public IDictionary AdditionalData { get; set; } + +#nullable enable + /// + /// Make sure that the return has all values set by order of priority: invoice/store/default + /// + /// + /// + /// + public static ReceiptOptions Merge(ReceiptOptions? storeLevelOption, ReceiptOptions? invoiceLevelOption) + { + storeLevelOption ??= new ReceiptOptions(); + invoiceLevelOption ??= new ReceiptOptions(); + var store = JObject.FromObject(storeLevelOption); + var inv = JObject.FromObject(invoiceLevelOption); + var result = JObject.FromObject(CreateDefault()); + var mergeSettings = new JsonMergeSettings() { MergeNullValueHandling = MergeNullValueHandling.Ignore }; + result.Merge(store, mergeSettings); + result.Merge(inv, mergeSettings); + var options = result.ToObject()!; + return options; + } + + public static ReceiptOptions CreateDefault() + { + return new ReceiptOptions() + { + ShowQR = true, + Enabled = true, + ShowPayments = true + }; + } +#nullable restore + } public class CheckoutOptions { diff --git a/BTCPayServer.Client/Models/StoreBaseData.cs b/BTCPayServer.Client/Models/StoreBaseData.cs index b5203436e9..33bcc71523 100644 --- a/BTCPayServer.Client/Models/StoreBaseData.cs +++ b/BTCPayServer.Client/Models/StoreBaseData.cs @@ -58,6 +58,8 @@ public abstract class StoreBaseData public NetworkFeeMode NetworkFeeMode { get; set; } = NetworkFeeMode.Never; public bool PayJoinEnabled { get; set; } + + public InvoiceData.ReceiptOptions Receipt { get; set; } [JsonExtensionData] diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index a6fbbfc258..1c5bbcbbe6 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -23,7 +23,7 @@ - + all diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index 9af352b666..c87ebc92ef 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -131,6 +131,39 @@ List GetImages(string content) } } + [Fact] + public void CanMergeReceiptOptions() + { + var r = InvoiceDataBase.ReceiptOptions.Merge(null, null); + Assert.True(r?.Enabled); + Assert.True(r?.ShowPayments); + Assert.True(r?.ShowQR); + + r = InvoiceDataBase.ReceiptOptions.Merge(new InvoiceDataBase.ReceiptOptions(), null); + Assert.True(r?.Enabled); + Assert.True(r?.ShowPayments); + Assert.True(r?.ShowQR); + + r = InvoiceDataBase.ReceiptOptions.Merge(new InvoiceDataBase.ReceiptOptions() { Enabled = false }, null); + Assert.False(r?.Enabled); + Assert.True(r?.ShowPayments); + Assert.True(r?.ShowQR); + + r = InvoiceDataBase.ReceiptOptions.Merge(new InvoiceDataBase.ReceiptOptions() { Enabled = false, ShowQR = false }, new InvoiceDataBase.ReceiptOptions() { Enabled = true }); + Assert.True(r?.Enabled); + Assert.True(r?.ShowPayments); + Assert.False(r?.ShowQR); + + StoreBlob blob = new StoreBlob(); + Assert.True(blob.ReceiptOptions.Enabled); + blob = JsonConvert.DeserializeObject("{}"); + Assert.True(blob.ReceiptOptions.Enabled); + blob = JsonConvert.DeserializeObject("{\"receiptOptions\":{\"enabled\": false}}"); + Assert.False(blob.ReceiptOptions.Enabled); + blob = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(blob)); + Assert.False(blob.ReceiptOptions.Enabled); + } + [Fact] public void CanParsePaymentMethodId() { diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 356de52e05..79cf33dd6c 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -86,9 +86,19 @@ public async Task StartAsync() Driver.AssertNoError(); } - public void PayInvoice() + public void PayInvoice(bool mine = false) { Driver.FindElement(By.Id("FakePayment")).Click(); + if (mine) + { + MineBlockOnInvoiceCheckout(); + } + } + + public void MineBlockOnInvoiceCheckout() + { + Driver.FindElement(By.CssSelector("#mine-block button")).Click(); + } /// diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 567dd6ce7e..9b823fd46b 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -14,6 +14,7 @@ using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; using BTCPayServer.Services.Wallets; using BTCPayServer.Views.Manage; using BTCPayServer.Views.Server; @@ -437,6 +438,61 @@ public async Task CanCreateInvoiceInUI() s.Driver.FindElements(By.ClassName("changeInvoiceState"))[0].Click(); TestUtils.Eventually(() => Assert.Contains("Settled (marked)", s.Driver.PageSource)); } + + [Fact(Timeout = TestTimeout)] + public async Task CanUseInvoiceReceipts() + { + using var s = CreateSeleniumTester(); + await s.StartAsync(); + s.RegisterNewUser(true); + s.CreateNewStore(); + s.AddDerivationScheme(); + s.GoToInvoices(); + var i = s.CreateInvoice(); + s.GoToInvoiceCheckout(i); + s.PayInvoice(true); + TestUtils.Eventually(() => s.Driver.FindElement(By.LinkText("View receipt")).Click()); + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.DoesNotContain("invoice-unsettled", s.Driver.PageSource); + Assert.DoesNotContain("invoice-processing", s.Driver.PageSource); + }); + + Assert.Contains(s.Server.PayTester.GetService().DisplayFormatCurrency(100, "USD"), + s.Driver.PageSource); + Assert.Contains(i, s.Driver.PageSource); + + s.GoToInvoices(s.StoreId); + i = s.CreateInvoice(); + s.GoToInvoiceCheckout(i); + var receipturl = s.Driver.Url + "/receipt"; + s.Driver.Navigate().GoToUrl(receipturl); + s.Driver.FindElement(By.Id("invoice-unsettled")); + + s.GoToInvoices(s.StoreId); + s.GoToInvoiceCheckout(i); + var checkouturi = s.Driver.Url; + s.PayInvoice(); + TestUtils.Eventually(() => s.Driver.FindElement(By.LinkText("View receipt")).Click()); + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.DoesNotContain("invoice-unsettled", s.Driver.PageSource); + Assert.Contains("invoice-processing", s.Driver.PageSource); + }); + s.GoToUrl(checkouturi); + s.MineBlockOnInvoiceCheckout(); + + TestUtils.Eventually(() => s.Driver.FindElement(By.LinkText("View receipt")).Click()); + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.DoesNotContain("invoice-unsettled", s.Driver.PageSource); + Assert.DoesNotContain("invoice-processing", s.Driver.PageSource); + }); + + } [Fact(Timeout = TestTimeout)] public async Task CanSetupStoreViaGuide() diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index b3e1e40ae9..1253b51e7b 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -57,7 +57,7 @@ - + diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index f9abff9012..d1fea50934 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -431,7 +431,8 @@ private InvoiceData ToModel(InvoiceEntity entity) RedirectAutomatically = entity.RedirectAutomatically, RequiresRefundEmail = entity.RequiresRefundEmail, RedirectURL = entity.RedirectURLTemplate - } + }, + Receipt = entity.ReceiptOptions }; } } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs index 317934f599..18d4643093 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs @@ -25,13 +25,11 @@ public class GreenfieldStoresController : ControllerBase { private readonly StoreRepository _storeRepository; private readonly UserManager _userManager; - private readonly BTCPayNetworkProvider _btcPayNetworkProvider; - public GreenfieldStoresController(StoreRepository storeRepository, UserManager userManager, BTCPayNetworkProvider btcPayNetworkProvider) + public GreenfieldStoresController(StoreRepository storeRepository, UserManager userManager) { _storeRepository = storeRepository; _userManager = userManager; - _btcPayNetworkProvider = btcPayNetworkProvider; } [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/stores")] @@ -129,6 +127,7 @@ private Client.Models.StoreData FromModel(Data.StoreData data) //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, + Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null), LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi, LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints, OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback, @@ -166,6 +165,7 @@ private static void ToModel(StoreBaseData restModel, Data.StoreData model, Payme blob.NetworkFeeMode = restModel.NetworkFeeMode; blob.DefaultCurrency = restModel.DefaultCurrency; blob.RequiresRefundEmail = restModel.RequiresRefundEmail; + blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null); blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi; blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints; blob.OnChainWithLnInvoiceFallback = restModel.OnChainWithLnInvoiceFallback; diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index f7b392d151..8ba4ea4563 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -17,6 +18,7 @@ using BTCPayServer.HostedServices; using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.Payments; using BTCPayServer.Rating; using BTCPayServer.Services.Apps; @@ -26,6 +28,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitpayClient; @@ -83,7 +86,7 @@ public async Task RedeliverWebhook(string storeId, string invoice } [HttpGet("invoices/{invoiceId}")] - [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task Invoice(string invoiceId) { var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery @@ -101,7 +104,8 @@ public async Task Invoice(string invoiceId) var store = await _StoreRepository.FindStore(invoice.StoreId); if (store == null) return NotFound(); - + + var receipt = InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, invoice.ReceiptOptions); var invoiceState = invoice.GetInvoiceState(); var model = new InvoiceDetailsModel { @@ -133,6 +137,7 @@ public async Task Invoice(string invoiceId) CanRefund = CanRefund(invoiceState), Refunds = invoice.Refunds, ShowCheckout = invoice.Status == InvoiceStatusLegacy.New, + ShowReceipt = invoice.Status.ToModernStatus() == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true), Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId)) .Select(c => new Models.StoreViewModels.DeliveryViewModel(c)) .ToList(), @@ -146,7 +151,85 @@ public async Task Invoice(string invoiceId) return View(model); } + + [HttpGet("i/{invoiceId}/receipt")] + public async Task InvoiceReceipt(string invoiceId) + { + var i = await _InvoiceRepository.GetInvoice(invoiceId); + if (i is null) + return NotFound(); + var store = await _StoreRepository.GetStoreByInvoiceId(i.Id); + if (store is null) + return NotFound(); + + var receipt = InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, i.ReceiptOptions); + + if (receipt.Enabled is not true) return NotFound(); + if (i.Status.ToModernStatus() != InvoiceStatus.Settled) + { + return View(new InvoiceReceiptViewModel + { + InvoiceId = i.Id, + OrderId = i.Metadata?.OrderId, + StoreName = store.StoreName, + Status = i.Status.ToModernStatus() + }); + } + JToken? receiptData = null; + i.Metadata?.AdditionalData.TryGetValue("receiptData", out receiptData); + + return View(new InvoiceReceiptViewModel + { + StoreName = store.StoreName, + Status = i.Status.ToModernStatus(), + Amount = i.Price, + Currency = i.Currency, + Timestamp = i.InvoiceTime, + InvoiceId = i.Id, + OrderId = i.Metadata?.OrderId, + Payments = receipt.ShowPayments is false ? null : i.GetPayments(true).Select(paymentEntity => + { + var paymentData = paymentEntity.GetCryptoPaymentData(); + var paymentMethodId = paymentEntity.GetPaymentMethodId(); + if (paymentData is null || paymentMethodId is null) + { + return null; + } + + string txId = paymentData.GetPaymentId(); + string? link = GetTransactionLink(paymentMethodId, txId); + var paymentMethod = i.GetPaymentMethod(paymentMethodId); + var amount = paymentData.GetValue(); + var rate = paymentMethod.Rate; + var paid = (amount - paymentEntity.NetworkFee) * rate; + + return new ViewPaymentRequestViewModel.PaymentRequestInvoicePayment + { + Amount = amount, + Paid = paid, + ReceivedDate = paymentEntity.ReceivedTime.DateTime, + PaidFormatted = _CurrencyNameTable.FormatCurrency(paid, i.Currency), + RateFormatted = _CurrencyNameTable.FormatCurrency(rate, i.Currency), + PaymentMethod = paymentMethodId.ToPrettyString(), + Link = link, + Id = txId, + Destination = paymentData.GetDestination() + }; + }) + .Where(payment => payment != null) + .ToList(), + ReceiptOptions = receipt, + AdditionalData = receiptData is null + ? new Dictionary() + : PosDataParser.ParsePosData(receiptData.ToString()) + }); + } + private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId) + { + var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + return network == null ? null : paymentMethodId.PaymentType.GetTransactionLink(network, txId); + } bool CanRefund(InvoiceState invoiceState) { return invoiceState.Status == InvoiceStatusLegacy.Confirmed || @@ -632,6 +715,15 @@ public async Task CheckoutNoScript(string? invoiceId, string? id } lang ??= storeBlob.DefaultLang; + var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true; + var receiptUrl = receiptEnabled? _linkGenerator.GetUriByAction( + nameof(UIInvoiceController.InvoiceReceipt), + "UIInvoice", + new {invoiceId}, + Request.Scheme, + Request.Host, + Request.PathBase) : null; + var model = new PaymentModel { Activated = paymentMethodDetails.Activated, @@ -657,7 +749,8 @@ public async Task CheckoutNoScript(string? invoiceId, string? id MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes, ItemDesc = invoice.Metadata.ItemDesc, Rate = ExchangeRate(paymentMethod), - MerchantRefLink = invoice.RedirectURL?.AbsoluteUri ?? "/", + MerchantRefLink = invoice.RedirectURL?.AbsoluteUri ?? receiptUrl ?? "/", + ReceiptLink = receiptUrl, RedirectAutomatically = invoice.RedirectAutomatically, StoreName = store.StoreName, TxCount = accounting.TxRequired, @@ -1079,25 +1172,7 @@ public static Dictionary ParsePosData(string posData) var jObject = JObject.Parse(posData); foreach (var item in jObject) { - switch (item.Value?.Type) - { - case JTokenType.Array: - var items = item.Value.AsEnumerable().ToList(); - for (var i = 0; i < items.Count; i++) - { - result.TryAdd($"{item.Key}[{i}]", ParsePosData(items[i].ToString())); - } - break; - case JTokenType.Object: - result.TryAdd(item.Key, ParsePosData(item.Value.ToString())); - break; - case null: - break; - default: - result.TryAdd(item.Key, item.Value.ToString()); - break; - } - + ParsePosDataItem(item, ref result); } } catch @@ -1106,6 +1181,29 @@ public static Dictionary ParsePosData(string posData) } return result; } + + public static void ParsePosDataItem(KeyValuePair item, ref Dictionary result) + { + switch (item.Value?.Type) + { + case JTokenType.Array: + var items = item.Value.AsEnumerable().ToList(); + for (var i = 0; i < items.Count; i++) + { + result.TryAdd($"{item.Key}[{i}]", ParsePosData(items[i].ToString())); + } + + break; + case JTokenType.Object: + result.TryAdd(item.Key, ParsePosData(item.Value.ToString())); + break; + case null: + break; + default: + result.TryAdd(item.Key, item.Value.ToString()); + break; + } + } } } } diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index b1360da233..1dd7db6dea 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -22,6 +22,7 @@ using BTCPayServer.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using NBitpayClient; using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; using StoreData = BTCPayServer.Data.StoreData; @@ -44,6 +45,7 @@ public partial class UIInvoiceController : Controller private readonly LanguageService _languageService; private readonly ExplorerClientProvider _ExplorerClients; private readonly UIWalletsController _walletsController; + private readonly LinkGenerator _linkGenerator; public WebhookSender WebhookNotificationManager { get; } @@ -62,7 +64,8 @@ public UIInvoiceController( WebhookSender webhookNotificationManager, LanguageService languageService, ExplorerClientProvider explorerClients, - UIWalletsController walletsController) + UIWalletsController walletsController, + LinkGenerator linkGenerator) { _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); _StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository)); @@ -78,6 +81,7 @@ public UIInvoiceController( _languageService = languageService; this._ExplorerClients = explorerClients; _walletsController = walletsController; + _linkGenerator = linkGenerator; } @@ -159,6 +163,7 @@ internal async Task CreateInvoiceCoreRaw(BitpayCreateInvoiceReque entity.PaymentTolerance = storeBlob.PaymentTolerance; entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod; entity.RequiresRefundEmail = invoice.RequiresRefundEmail; + return await CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken); } @@ -169,6 +174,7 @@ internal async Task CreateInvoiceCoreRaw(CreateInvoiceRequest inv entity.ServerUrl = serverUrl; entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration); entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration); + entity.ReceiptOptions = invoice.Receipt ?? new InvoiceDataBase.ReceiptOptions(); if (invoice.Metadata != null) entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata); invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions(); diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index e3debec5cc..2a1d52fa65 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -342,7 +342,8 @@ public async Task GetLNURL(string cryptoCode, string storeId, str return NotFound("Store not found"); } - currencyCode ??= store.GetStoreBlob().DefaultCurrency ?? cryptoCode; + var storeBlob = store.GetStoreBlob(); + currencyCode ??= storeBlob.DefaultCurrency ?? cryptoCode; var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); @@ -429,7 +430,6 @@ public async Task GetLNURL(string cryptoCode, string storeId, str { lnurlMetadata.Add(new[] {"text/identifier", lnAddress}); } - return Ok(new LNURLPayRequest { Tag = "payRequest", @@ -513,6 +513,18 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry return BadRequest(new LNUrlStatusResponse {Status = "ERROR", Reason = "Amount is out of bounds."}); } + LNURLPayRequest.LNURLPayRequestCallbackResponse.ILNURLPayRequestSuccessAction successAction = null; + + if ((i.ReceiptOptions?.Enabled ??blob.ReceiptOptions.Enabled ) is true) + { + successAction = + new LNURLPayRequest.LNURLPayRequestCallbackResponse.LNURLPayRequestSuccessActionUrl() + { + Tag = "url", + Description = "Thank you for your purchase. Here is your receipt", + Url = _linkGenerator.GetUriByAction(HttpContext, "InvoiceReceipt", "UIInvoice", new { invoiceId}) + }; + } if (amount.HasValue && string.IsNullOrEmpty(paymentMethodDetails.BOLT11) || paymentMethodDetails.GeneratedBoltAmount != amount) { @@ -573,7 +585,8 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry paymentMethodDetails, pmi)); return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse { - Disposable = true, Routes = Array.Empty(), Pr = paymentMethodDetails.BOLT11 + Disposable = true, Routes = Array.Empty(), Pr = paymentMethodDetails.BOLT11, + SuccessAction = successAction }); } @@ -588,7 +601,8 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse { - Disposable = true, Routes = Array.Empty(), Pr = paymentMethodDetails.BOLT11 + Disposable = true, Routes = Array.Empty(), Pr = paymentMethodDetails.BOLT11, + SuccessAction = successAction }); } diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index 19f2d616cd..13c28761cf 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -385,6 +385,7 @@ public IActionResult CheckoutAppearance() vm.CustomCSS = storeBlob.CustomCSS; vm.CustomLogo = storeBlob.CustomLogo; vm.HtmlTitle = storeBlob.HtmlTitle; + vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions); vm.AutoDetectLanguage = storeBlob.AutoDetectLanguage; vm.SetLanguages(_LangService, storeBlob.DefaultLang); @@ -496,6 +497,7 @@ public async Task CheckoutAppearance(CheckoutAppearanceViewModel blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.LazyPaymentMethods = model.LazyPaymentMethods; blob.RedirectAutomatically = model.RedirectAutomatically; + blob.ReceiptOptions = model.ReceiptOptions.ToDTO(); blob.CustomLogo = model.CustomLogo; blob.CustomCSS = model.CustomCSS; blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle; diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index aa13afd78b..772d0a699b 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -30,6 +30,7 @@ public StoreBlob() ShowRecommendedFee = true; RecommendedFeeBlockTarget = 1; PaymentMethodCriteria = new List(); + ReceiptOptions = InvoiceDataBase.ReceiptOptions.CreateDefault(); } [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] @@ -104,7 +105,10 @@ public TimeSpan MonitoringExpiration public bool AutoDetectLanguage { get; set; } public bool RateScripting { get; set; } - +#nullable enable + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } +#nullable restore public string RateScript { get; set; } public bool AnyoneCanInvoice { get; set; } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index a4337f55c9..0458fd80a9 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Data; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Services.Invoices; @@ -133,5 +134,6 @@ public string Fiat public bool CanMarkInvalid { get; set; } public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid; public List Refunds { get; set; } + public bool ShowReceipt { get; set; } } } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs new file mode 100644 index 0000000000..42682d0933 --- /dev/null +++ b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using BTCPayServer.Client.Models; +using BTCPayServer.Models.PaymentRequestViewModels; +using static BTCPayServer.Client.Models.InvoiceDataBase; + +namespace BTCPayServer.Models.InvoicingModels +{ + public class InvoiceReceiptViewModel + { + public InvoiceStatus Status { get; set; } + public string InvoiceId { get; set; } + public string OrderId { get; set; } + public string Currency { get; set; } + public string StoreName { get; set; } + public decimal Amount { get; set; } + public DateTimeOffset Timestamp { get; set; } + public Dictionary AdditionalData { get; set; } + public ReceiptOptions ReceiptOptions { get; set; } + public List Payments { get; set; } + } +} diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index 55f932f682..dfe5bba6c1 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -68,5 +68,6 @@ public class AvailableCrypto public bool RedirectAutomatically { get; set; } public bool Activated { get; set; } public string InvoiceCurrency { get; set; } + public string? ReceiptLink { get; set; } } } diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs index a2ba37a63c..0e49d72a07 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using BTCPayServer.Services; using Microsoft.AspNetCore.Mvc.Rendering; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Models.StoreViewModels { @@ -47,6 +48,26 @@ public void SetLanguages(LanguageService langService, string defaultLang) [Display(Name = "Custom HTML title to display on Checkout page")] public string HtmlTitle { get; set; } + public class ReceiptOptionsViewModel + { + public static ReceiptOptionsViewModel Create(Client.Models.InvoiceDataBase.ReceiptOptions opts) + { + return JObject.FromObject(opts).ToObject(); + } + [Display(Name = "Enable public receipt page for settled invoices")] + public bool Enabled { get; set; } + + [Display(Name = "Show the QR code of the receipt in the public receipt page")] + public bool ShowQR { get; set; } + + [Display(Name = "Show the payment list in the public receipt page")] + public bool ShowPayments { get; set; } + public Client.Models.InvoiceDataBase.ReceiptOptions ToDTO() + { + return JObject.FromObject(this).ToObject(); + } + } + public ReceiptOptionsViewModel ReceiptOptions { get; set; } = ReceiptOptionsViewModel.Create(Client.Models.InvoiceDataBase.ReceiptOptions.CreateDefault()); public List PaymentMethodCriteria { get; set; } } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 42bbb5001e..c406ee57e1 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -442,6 +442,9 @@ private Uri FillPlaceholdersUri(string v) public InvoiceType Type { get; set; } public List Refunds { get; set; } + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } public bool IsExpired() { diff --git a/BTCPayServer/Views/UIInvoice/Checkout-Body.cshtml b/BTCPayServer/Views/UIInvoice/Checkout-Body.cshtml index 97a298116b..700668be14 100644 --- a/BTCPayServer/Views/UIInvoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/UIInvoice/Checkout-Body.cshtml @@ -204,8 +204,12 @@
{{$t("This invoice has been paid")}}
- - + + + + + + + }
+
Amount Paid
+ +
@CurrencyNameTable.DisplayFormatCurrency(Model.Amount, Model.Currency)
+ +
+
Date
+
@Model.Timestamp.ToBrowserDate()
+
+ @if (!string.IsNullOrEmpty(Model.OrderId)) + { +
+
Order ID
+
@Model.OrderId
+
+ } + + } + + + @if (isProcessing) + { + This page will refresh periodically until the invoice is settled. + } + else if (isSettled) + { + if (Model.Payments?.Any() is true) + { +
+

Payment Details

+
+ + + + + + + + + @foreach (var payment in Model.Payments) + { + + + + + + + + + + + } +
DestinationReceivedPaidRatePayment
+ @payment.Destination + @payment.ReceivedDate.ToString("g")@payment.PaidFormatted@payment.RateFormatted@payment.Amount @payment.PaymentMethod
+ Transaction Id: + @if (!string.IsNullOrEmpty(payment.Link)) + { + @payment.Id + } + else + { + @payment.Id + } +
+
+
+ } + if (Model.AdditionalData?.Any() is true) + { +
+

Additional Data

+
+ +
+
+ } + } + + + + + + + + + diff --git a/BTCPayServer/Views/UIInvoice/PosData.cshtml b/BTCPayServer/Views/UIInvoice/PosData.cshtml index d305ba54db..c30de63d62 100644 --- a/BTCPayServer/Views/UIInvoice/PosData.cshtml +++ b/BTCPayServer/Views/UIInvoice/PosData.cshtml @@ -1,6 +1,6 @@ @model (Dictionary Items, int Level) - +
@foreach (var (key, value) in Model.Items) { @@ -8,17 +8,17 @@ { if (!string.IsNullOrEmpty(key)) { - + } } else if (value is DictionarysubItems) @@ -26,22 +26,22 @@ @* This is the array case *@ if (subItems.Count == 1 && subItems.First().Value is string str2) { - + } else { - } diff --git a/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml b/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml index 8c286d4c02..6249819906 100644 --- a/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml +++ b/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml @@ -238,128 +238,129 @@

Payment History

-
-
@key@Safe.Raw(key) - @if (str.StartsWith("http")) - { - @str - } - else - { - @value - } + @if (Uri.IsWellFormedUriString(str, UriKind.RelativeOrAbsolute)) + { + @Safe.Raw(str) + } + else + { + @Safe.Raw(value?.ToString()) + } @key@Safe.Raw(key) - @if (str2.StartsWith("http")) + @if (Uri.IsWellFormedUriString(str2, UriKind.RelativeOrAbsolute)) { - @str2 + @Safe.Raw(str2) } else { - @subItems.First().Value + @Safe.Raw(subItems.First().Value?.ToString()) } - @Html.Raw($"{key}") + + @Safe.Raw($"{key}")
- - - - - - - + + + + + + + + + + + + + + + @if (invoice.Payments != null && invoice.Payments.Any()) + { - - - - - + + + + + - @if (invoice.Payments != null && invoice.Payments.Any()) + @foreach (var payment in invoice.Payments) { - - - - - + + + + + + + + - @foreach (var payment in invoice.Payments) - { - - - - - - - - - - - } } + }
Invoice IdExpiryAmountStatus
Invoice IdExpiryAmountStatus
@invoice.Id@invoice.ExpiryDate.ToString("g")@invoice.AmountFormatted + @invoice.StateFormatted +
@invoice.Id@invoice.ExpiryDate.ToString("g")@invoice.AmountFormatted - @invoice.StateFormatted - DestinationReceivedPaidRatePayment
DestinationReceivedPaidRatePayment@payment.Destination@payment.ReceivedDate.ToString("g")@payment.PaidFormatted@payment.RateFormatted@payment.Amount @payment.PaymentMethod
+ Transaction Id: + @if (!string.IsNullOrEmpty(payment.Link)) + { + @payment.Id + } + else + { + @payment.Id + } +
@payment.Destination@payment.ReceivedDate.ToString("g")@payment.PaidFormatted@payment.RateFormatted@payment.Amount @payment.PaymentMethod
- Transaction Id: - @if (!string.IsNullOrEmpty(payment.Link)) - { - @payment.Id - } - else - { - @payment.Id - } -
- } + } - - - -