diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index a369bec67..956defe20 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -34,6 +34,7 @@ using System.Security.Principal; using System.Text; using System.Threading; using Xunit; +using BTCPayServer.Services; namespace BTCPayServer.Tests { diff --git a/BTCPayServer.Tests/CustomerHttpServer.cs b/BTCPayServer.Tests/CustomerHttpServer.cs index 24f73c8ef..34cbdefdc 100644 --- a/BTCPayServer.Tests/CustomerHttpServer.cs +++ b/BTCPayServer.Tests/CustomerHttpServer.cs @@ -8,30 +8,27 @@ using Microsoft.AspNetCore.Builder; using System.Threading.Tasks; using System.Threading; using Microsoft.AspNetCore.Hosting.Server.Features; +using System.Threading.Channels; +using System.IO; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; namespace BTCPayServer.Tests { public class CustomServer : IDisposable { - TaskCompletionSource _Evt = null; IWebHost _Host = null; CancellationTokenSource _Closed = new CancellationTokenSource(); + Channel _Requests = Channel.CreateUnbounded(); public CustomServer() - { + { var port = Utils.FreeTcpPort(); _Host = new WebHostBuilder() .Configure(app => { app.Run(req => { - while (_Act == null) - { - Thread.Sleep(10); - _Closed.Token.ThrowIfCancellationRequested(); - } - _Act(req); - _Act = null; - _Evt.TrySetResult(true); + _Requests.Writer.WriteAsync(JsonConvert.DeserializeObject(new StreamReader(req.Request.Body).ReadToEnd()), _Closed.Token); req.Response.StatusCode = 200; return Task.CompletedTask; }); @@ -47,22 +44,24 @@ namespace BTCPayServer.Tests return new Uri(_Host.ServerFeatures.Get().Addresses.First()); } - Action _Act; - public void ProcessNextRequest(Action act) + public async Task GetNextRequest() { - var source = new TaskCompletionSource(); - CancellationTokenSource cancellation = new CancellationTokenSource(20000); - cancellation.Token.Register(() => source.TrySetCanceled()); - source = new TaskCompletionSource(); - _Evt = source; - _Act = act; - try + using (CancellationTokenSource cancellation = new CancellationTokenSource(2000000)) { - _Evt.Task.GetAwaiter().GetResult(); - } - catch (TaskCanceledException) - { - throw new Xunit.Sdk.XunitException("Callback to the webserver was expected, check if the callback url is accessible from internet"); + try + { + JObject req = null; + while(!await _Requests.Reader.WaitToReadAsync(cancellation.Token) || + !_Requests.Reader.TryRead(out req)) + { + + } + return req; + } + catch (TaskCanceledException) + { + throw new Xunit.Sdk.XunitException("Callback to the webserver was expected, check if the callback url is accessible from internet"); + } } } diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 2129776cd..db8e71914 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -22,6 +22,7 @@ using BTCPayServer.Tests.Lnd; using BTCPayServer.Payments.Lightning; using BTCPayServer.Lightning.CLightning; using BTCPayServer.Lightning; +using BTCPayServer.Services; namespace BTCPayServer.Tests { @@ -152,6 +153,7 @@ namespace BTCPayServer.Tests { get; set; } + public List Stores { get; internal set; } = new List(); public void Dispose() diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 5669a8818..5ef6b0b08 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -17,6 +17,7 @@ using BTCPayServer.Payments.Lightning; using BTCPayServer.Tests.Logging; using BTCPayServer.Lightning; using BTCPayServer.Lightning.CLightning; +using BTCPayServer.Data; namespace BTCPayServer.Tests { @@ -58,6 +59,21 @@ namespace BTCPayServer.Tests CreateStoreAsync().GetAwaiter().GetResult(); } + public void SetNetworkFeeMode(NetworkFeeMode mode) + { + ModifyStore((store) => + { + store.NetworkFeeMode = mode; + }); + } + public void ModifyStore(Action modify) + { + var storeController = GetController(); + StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model; + modify(store); + storeController.UpdateStore(store).GetAwaiter().GetResult(); + } + public T GetController(bool setImplicitStore = true) where T : Controller { return parent.PayTester.GetController(UserId, setImplicitStore ? StoreId : null); @@ -83,10 +99,6 @@ namespace BTCPayServer.Tests var store = parent.PayTester.GetController(UserId, StoreId); ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork); DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + (segwit ? "" : "-[legacy]")); - var vm = (StoreViewModel)((ViewResult)store.UpdateStore()).Model; - vm.SpeedPolicy = SpeedPolicy.MediumSpeed; - await store.UpdateStore(vm); - await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel() { DerivationScheme = DerivationScheme.ToString(), diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index eab823615..6c94c1115 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -49,6 +49,8 @@ using BTCPayServer.Security; using NBXplorer.Models; using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel; using NBitpayClient.Extensions; +using BTCPayServer.Services; +using System.Text.RegularExpressions; namespace BTCPayServer.Tests { @@ -98,7 +100,7 @@ namespace BTCPayServer.Tests Rate = 10513.44m, }.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() { - TxFee = Money.Coins(0.00000100m), + NetworkFee = Money.Coins(0.00000100m), DepositAddress = dummy })); paymentMethods.Add(new PaymentMethod() @@ -107,7 +109,7 @@ namespace BTCPayServer.Tests Rate = 216.79m }.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() { - TxFee = Money.Coins(0.00010000m), + NetworkFee = Money.Coins(0.00010000m), DepositAddress = dummy })); invoiceEntity.SetPaymentMethods(paymentMethods); @@ -115,12 +117,12 @@ namespace BTCPayServer.Tests var btc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null); var accounting = btc.Calculate(); - invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData() + invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC", NetworkFee = 0.00000100m }.SetCryptoPaymentData(new BitcoinLikePaymentData() { Output = new TxOut() { Value = Money.Coins(0.00151263m) } })); accounting = btc.Calculate(); - invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData() + invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC", NetworkFee = 0.00000100m }.SetCryptoPaymentData(new BitcoinLikePaymentData() { Output = new TxOut() { Value = accounting.Due } })); @@ -147,7 +149,7 @@ namespace BTCPayServer.Tests var entity = new InvoiceEntity(); #pragma warning disable CS0618 entity.Payments = new System.Collections.Generic.List(); - entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, TxFee = Money.Coins(0.1m) }); + entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, NetworkFee = Money.Coins(0.1m) }); entity.ProductInformation = new ProductInformation() { Price = 5000 }; var paymentMethod = entity.GetPaymentMethods(null).TryGet("BTC", PaymentTypes.BTCLike); @@ -155,20 +157,20 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.1m), accounting.Due); Assert.Equal(Money.Coins(1.1m), accounting.TotalDue); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()), Accounted = true, NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); //Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1 Assert.Equal(Money.Coins(0.7m), accounting.Due); Assert.Equal(Money.Coins(1.2m), accounting.TotalDue); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true, NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); Assert.Equal(Money.Coins(0.6m), accounting.Due); Assert.Equal(Money.Coins(1.3m), accounting.TotalDue); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()), Accounted = true, NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); Assert.Equal(Money.Zero, accounting.Due); @@ -187,13 +189,13 @@ namespace BTCPayServer.Tests { CryptoCode = "BTC", Rate = 1000, - TxFee = Money.Coins(0.1m) + NetworkFee = Money.Coins(0.1m) }); paymentMethods.Add(new PaymentMethod() { CryptoCode = "LTC", Rate = 500, - TxFee = Money.Coins(0.01m) + NetworkFee = Money.Coins(0.01m) }); entity.SetPaymentMethods(paymentMethods); entity.Payments = new List(); @@ -205,7 +207,7 @@ namespace BTCPayServer.Tests accounting = paymentMethod.Calculate(); Assert.Equal(Money.Coins(10.01m), accounting.TotalDue); - entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true, NetworkFee = 0.1m }); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null); accounting = paymentMethod.Calculate(); @@ -222,7 +224,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(2.0m), accounting.Paid); Assert.Equal(Money.Coins(10.01m + 0.1m * 2), accounting.TotalDue); - entity.Payments.Add(new PaymentEntity() { CryptoCode = "LTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { CryptoCode = "LTC", Output = new TxOut(Money.Coins(1.0m), new Key()), Accounted = true, NetworkFee = 0.01m }); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null); @@ -242,7 +244,7 @@ namespace BTCPayServer.Tests Assert.Equal(2, accounting.TxRequired); var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2); - entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(remaining, new Key()), Accounted = true }); + entity.Payments.Add(new PaymentEntity() { CryptoCode = "BTC", Output = new TxOut(remaining, new Key()), Accounted = true, NetworkFee = 0.1m }); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null); accounting = paymentMethod.Calculate(); @@ -272,7 +274,7 @@ namespace BTCPayServer.Tests var entity = new InvoiceEntity(); #pragma warning disable CS0618 entity.Payments = new List(); - entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, TxFee = Money.Coins(0.1m) }); + entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, NetworkFee = Money.Coins(0.1m) }); entity.ProductInformation = new ProductInformation() { Price = 5000 }; entity.PaymentTolerance = 0; @@ -349,7 +351,7 @@ namespace BTCPayServer.Tests (1000.0001m, "₹ 1,000.00 (INR)", "INR") }) { - var actual = new CurrencyNameTable().DisplayFormatCurrency(test.Item1, test.Item3); + var actual = new CurrencyNameTable().DisplayFormatCurrency(test.Item1, test.Item3); actual = actual.Replace("¥", "¥"); // Hack so JPY test pass on linux as well Assert.Equal(test.Item2, actual); } @@ -484,7 +486,7 @@ namespace BTCPayServer.Tests [Fact] [Trait("Integration", "Integration")] - public void CanSendIPN() + public async Task CanSendIPN() { using (var callbackServer = new CustomServer()) { @@ -494,6 +496,7 @@ namespace BTCPayServer.Tests var acc = tester.NewAccount(); acc.GrantAccess(); acc.RegisterDerivationScheme("BTC"); + acc.ModifyStore(s => s.SpeedPolicy = SpeedPolicy.LowSpeed); var invoice = acc.BitPay.CreateInvoice(new Invoice() { Price = 5.0m, @@ -506,13 +509,43 @@ namespace BTCPayServer.Tests ExtendedNotifications = true }); BitcoinUrlBuilder url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP21); - tester.ExplorerNode.SendToAddress(url.Address, url.Amount); - Thread.Sleep(5000); - callbackServer.ProcessNextRequest((ctx) => + bool receivedPayment = false; + bool paid = false; + bool confirmed = false; + bool completed = false; + while (!completed || !confirmed) { - var ipn = new StreamReader(ctx.Request.Body).ReadToEnd(); - JsonConvert.DeserializeObject(ipn); //can deserialize - }); + var request = await callbackServer.GetNextRequest(); + if (request.ContainsKey("event")) + { + var evtName = request["event"]["name"].Value(); + switch (evtName) + { + case "invoice_created": + tester.ExplorerNode.SendToAddress(url.Address, url.Amount); + break; + case "invoice_receivedPayment": + receivedPayment = true; + break; + case "invoice_paidInFull": + Assert.True(receivedPayment); + tester.ExplorerNode.Generate(6); + paid = true; + break; + case "invoice_confirmed": + Assert.True(paid); + confirmed = true; + break; + case "invoice_completed": + Assert.True(paid); //TODO: Fix, out of order event mean we can receive invoice_confirmed after invoice_complete + completed = true; + break; + default: + Assert.False(true, $"{evtName} was not expected"); + break; + } + } + } var invoice2 = acc.BitPay.GetInvoice(invoice.Id); Assert.NotNull(invoice2); } @@ -726,6 +759,7 @@ namespace BTCPayServer.Tests var user = tester.NewAccount(); user.GrantAccess(); user.RegisterDerivationScheme("BTC"); + user.SetNetworkFeeMode(NetworkFeeMode.Always); var invoice = user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0m, @@ -1429,6 +1463,7 @@ namespace BTCPayServer.Tests vmpos.ButtonText = "{0} Purchase"; vmpos.CustomButtonText = "Nicolas Sexy Hair"; vmpos.CustomTipText = "Wanna tip?"; + vmpos.CustomTipPercentages = "15,18,20"; vmpos.Template = @" apple: price: 5.0 @@ -1454,6 +1489,7 @@ donation: Assert.Equal("{0} Purchase", vmview.ButtonText); Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText); Assert.Equal("Wanna tip?", vmview.CustomTipText); + Assert.Equal("15,18,20", string.Join(',', vmview.CustomTipPercentages)); Assert.IsType(publicApps.ViewPointOfSale(appId, 0, null, null, null, null, "orange").Result); // @@ -1589,16 +1625,22 @@ donation: [Trait("Integration", "Integration")] public void CanExportInvoicesJson() { + decimal GetFieldValue(string input, string fieldName) + { + var match = Regex.Match(input, $"\"{fieldName}\":([^,]*)"); + Assert.True(match.Success); + return decimal.Parse(match.Groups[1].Value.Trim(), CultureInfo.InvariantCulture); + } using (var tester = ServerTester.Create()) { tester.Start(); var user = tester.NewAccount(); user.GrantAccess(); user.RegisterDerivationScheme("BTC"); - + user.SetNetworkFeeMode(NetworkFeeMode.Always); var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 500, + Price = 10, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1606,6 +1648,7 @@ donation: FullNotifications = true }, Facade.Merchant); + var networkFee = new FeeRate(invoice.MinerFees["BTC"].SatoshiPerBytes).GetFee(100); // ensure 0 invoices exported because there are no payments yet var jsonResult = user.GetController().Export("json").GetAwaiter().GetResult(); var result = Assert.IsType(jsonResult); @@ -1614,46 +1657,123 @@ donation: var cashCow = tester.ExplorerNode; var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network); - var firstPayment = invoice.CryptoInfo[0].TotalDue - Money.Satoshis(10); + // + var firstPayment = invoice.CryptoInfo[0].TotalDue - 3 * networkFee; cashCow.SendToAddress(invoiceAddress, firstPayment); + Thread.Sleep(1000); // prevent race conditions, ordering payments + // look if you can reduce thread sleep, this was min value for me + + // should reduce invoice due by 0 USD because payment = network fee + cashCow.SendToAddress(invoiceAddress, networkFee); + Thread.Sleep(1000); + + // pay remaining amount + cashCow.SendToAddress(invoiceAddress, 4 * networkFee); + Thread.Sleep(1000); Eventually(() => { var jsonResultPaid = user.GetController().Export("json").GetAwaiter().GetResult(); var paidresult = Assert.IsType(jsonResultPaid); Assert.Equal("application/json", paidresult.ContentType); - Assert.Contains("\"InvoiceItemDesc\": \"Some \\\", description\"", paidresult.Content); - Assert.Contains("\"InvoicePrice\": 500.0", paidresult.Content); - Assert.Contains("\"ConversionRate\": 5000.0", paidresult.Content); - Assert.Contains($"\"InvoiceId\": \"{invoice.Id}\",", paidresult.Content); - }); - /* -[ - { - "ReceivedDate": "2018-11-30T10:27:13Z", - "StoreId": "FKaSZrXLJ2tcLfCyeiYYfmZp1UM5nZ1LDecQqbwBRuHi", - "OrderId": "orderId", - "InvoiceId": "4XUkgPMaTBzwJGV9P84kPC", - "CreatedDate": "2018-11-30T10:27:06Z", - "ExpirationDate": "2018-11-30T10:42:06Z", - "MonitoringDate": "2018-11-30T11:42:06Z", - "PaymentId": "6e5755c3357b20fd66f5fc478778d81371eab341e7112ab66ed6122c0ec0d9e5-1", - "CryptoCode": "BTC", - "Destination": "mhhSEQuoM993o6vwnBeufJ4TaWov2ZUsPQ", - "PaymentType": "OnChain", - "PaymentDue": "0.10020000 BTC", - "PaymentPaid": "0.10009990 BTC", - "PaymentOverpaid": "0.00000000 BTC", - "ConversionRate": 5000.0, - "FiatPrice": 500.0, - "FiatCurrency": "USD", - "ItemCode": null, - "ItemDesc": "Some \", description", - "Status": "new" - } -] - */ + var parsedJson = JsonConvert.DeserializeObject(paidresult.Content); + Assert.Equal(3, parsedJson.Length); + + var invoiceDueAfterFirstPayment = (3 * networkFee).ToDecimal(MoneyUnit.BTC) * invoice.Rate; + var pay1str = parsedJson[0].ToString(); + Assert.Contains("\"InvoiceItemDesc\": \"Some \\\", description\"", pay1str); + Assert.Equal(invoiceDueAfterFirstPayment, GetFieldValue(pay1str, "InvoiceDue")); + Assert.Contains("\"InvoicePrice\": 10.0", pay1str); + Assert.Contains("\"ConversionRate\": 5000.0", pay1str); + Assert.Contains($"\"InvoiceId\": \"{invoice.Id}\",", pay1str); + + var pay2str = parsedJson[1].ToString(); + Assert.Equal(invoiceDueAfterFirstPayment, GetFieldValue(pay2str, "InvoiceDue")); + + var pay3str = parsedJson[2].ToString(); + Assert.Contains("\"InvoiceDue\": 0", pay3str); + }); + } + } + [Fact] + [Trait("Integration", "Integration")] + public void CanChangeNetworkFeeMode() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterDerivationScheme("BTC"); + foreach (var networkFeeMode in Enum.GetValues(typeof(NetworkFeeMode)).Cast()) + { + Logs.Tester.LogInformation($"Trying with {nameof(networkFeeMode)}={networkFeeMode}"); + user.SetNetworkFeeMode(networkFeeMode); + var invoice = user.BitPay.CreateInvoice(new Invoice() + { + Price = 10, + Currency = "USD", + PosData = "posData", + OrderId = "orderId", + ItemDesc = "Some \", description", + FullNotifications = true + }, Facade.Merchant); + + var networkFee = Money.Satoshis(10000).ToDecimal(MoneyUnit.BTC); + var missingMoney = Money.Satoshis(5000).ToDecimal(MoneyUnit.BTC); + var cashCow = tester.ExplorerNode; + var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network); + + // Check that for the first payment, no network fee are included + var due = Money.Parse(invoice.CryptoInfo[0].Due); + var productPartDue = (invoice.Price / invoice.Rate); + switch (networkFeeMode) + { + case NetworkFeeMode.MultiplePaymentsOnly: + case NetworkFeeMode.Never: + Assert.Equal(productPartDue, due.ToDecimal(MoneyUnit.BTC)); + break; + case NetworkFeeMode.Always: + Assert.Equal(productPartDue + networkFee, due.ToDecimal(MoneyUnit.BTC)); + break; + default: + throw new NotSupportedException(networkFeeMode.ToString()); + } + var firstPayment = productPartDue - missingMoney; + cashCow.SendToAddress(invoiceAddress, Money.Coins(firstPayment)); + + Eventually(() => + { + invoice = user.BitPay.GetInvoice(invoice.Id); + // Check that for the second payment, network fee are included + due = Money.Parse(invoice.CryptoInfo[0].Due); + Assert.Equal(Money.Coins(firstPayment), Money.Parse(invoice.CryptoInfo[0].Paid)); + switch (networkFeeMode) + { + case NetworkFeeMode.MultiplePaymentsOnly: + Assert.Equal(missingMoney + networkFee, due.ToDecimal(MoneyUnit.BTC)); + Assert.Equal(firstPayment + missingMoney + networkFee, Money.Parse(invoice.CryptoInfo[0].TotalDue).ToDecimal(MoneyUnit.BTC)); + break; + case NetworkFeeMode.Always: + Assert.Equal(missingMoney + 2 * networkFee, due.ToDecimal(MoneyUnit.BTC)); + Assert.Equal(firstPayment + missingMoney + 2 * networkFee, Money.Parse(invoice.CryptoInfo[0].TotalDue).ToDecimal(MoneyUnit.BTC)); + break; + case NetworkFeeMode.Never: + Assert.Equal(missingMoney, due.ToDecimal(MoneyUnit.BTC)); + Assert.Equal(firstPayment + missingMoney, Money.Parse(invoice.CryptoInfo[0].TotalDue).ToDecimal(MoneyUnit.BTC)); + break; + default: + throw new NotSupportedException(networkFeeMode.ToString()); + } + }); + cashCow.SendToAddress(invoiceAddress, due); + Eventually(() => + { + invoice = user.BitPay.GetInvoice(invoice.Id); + Assert.Equal("paid", invoice.Status); + }); + } } } @@ -1667,7 +1787,7 @@ donation: var user = tester.NewAccount(); user.GrantAccess(); user.RegisterDerivationScheme("BTC"); - + user.SetNetworkFeeMode(NetworkFeeMode.Always); var invoice = user.BitPay.CreateInvoice(new Invoice() { Price = 500, @@ -1689,8 +1809,8 @@ donation: var paidresult = Assert.IsType(exportResultPaid); Assert.Equal("application/csv", paidresult.ContentType); Assert.Contains($",\"orderId\",\"{invoice.Id}\",", paidresult.Content); - Assert.Contains($",\"OnChain\",\"0.1000999\",\"BTC\",\"5000.0\",\"500.0\"", paidresult.Content); - Assert.Contains($",\"USD\",\"\",\"Some ``, description\",\"new (paidPartial)\"", paidresult.Content); + Assert.Contains($",\"OnChain\",\"BTC\",\"0.1000999\",\"0.0001\",\"5000.0\"", paidresult.Content); + Assert.Contains($",\"USD\",\"0.00050000\",\"500.0\",\"\",\"Some ``, description\",\"new (paidPartial)\"", paidresult.Content); }); } } diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index e7089172d..f76d5e023 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -145,7 +145,7 @@ services: - bitcoind lightning-charged: - image: shesek/lightning-charge:0.4.3 + image: shesek/lightning-charge:0.4.6-standalone restart: unless-stopped environment: NETWORK: regtest diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 73239a3ec..304fc2429 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.3.33 + 1.0.3.36 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Controllers/AppsController.PointOfSale.cs b/BTCPayServer/Controllers/AppsController.PointOfSale.cs index c0245f7ac..22f4814e4 100644 --- a/BTCPayServer/Controllers/AppsController.PointOfSale.cs +++ b/BTCPayServer/Controllers/AppsController.PointOfSale.cs @@ -1,5 +1,8 @@ -using System.Text; +using System; +using System.Linq; +using System.Text; using System.Text.Encodings.Web; +using System.Text.RegularExpressions; using System.Threading.Tasks; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; @@ -65,6 +68,9 @@ namespace BTCPayServer.Controllers public string CustomButtonText { get; set; } = CUSTOM_BUTTON_TEXT_DEF; public const string CUSTOM_TIP_TEXT_DEF = "Do you want to leave a tip?"; public string CustomTipText { get; set; } = CUSTOM_TIP_TEXT_DEF; + public static readonly int[] CUSTOM_TIP_PERCENTAGES_DEF = new int[] { 15, 18, 20 }; + public int[] CustomTipPercentages { get; set; } = CUSTOM_TIP_PERCENTAGES_DEF; + public string CustomCSSLink { get; set; } } @@ -87,6 +93,7 @@ namespace BTCPayServer.Controllers ButtonText = settings.ButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF, CustomButtonText = settings.CustomButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF, CustomTipText = settings.CustomTipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF, + CustomTipPercentages = settings.CustomTipPercentages != null ? string.Join(",", settings.CustomTipPercentages) : string.Join(",", PointOfSaleSettings.CUSTOM_TIP_PERCENTAGES_DEF), CustomCSSLink = settings.CustomCSSLink }; if (HttpContext?.Request != null) @@ -157,6 +164,7 @@ namespace BTCPayServer.Controllers ButtonText = vm.ButtonText, CustomButtonText = vm.CustomButtonText, CustomTipText = vm.CustomTipText, + CustomTipPercentages = ListSplit(vm.CustomTipPercentages), CustomCSSLink = vm.CustomCSSLink }); await UpdateAppSettings(app); @@ -174,5 +182,21 @@ namespace BTCPayServer.Controllers await ctx.SaveChangesAsync(); } } + + private int[] ListSplit(string list, string separator = ",") + { + if (string.IsNullOrEmpty(list)) + { + return Array.Empty(); + } + else + { + // Remove all characters except numeric and comma + Regex charsToDestroy = new Regex(@"[^\d|\" + separator + "]"); + list = charsToDestroy.Replace(list, ""); + + return list.Split(separator, System.StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray(); + } + } } } diff --git a/BTCPayServer/Controllers/AppsPublicController.cs b/BTCPayServer/Controllers/AppsPublicController.cs index 344ec39fd..eb8391908 100644 --- a/BTCPayServer/Controllers/AppsPublicController.cs +++ b/BTCPayServer/Controllers/AppsPublicController.cs @@ -65,7 +65,9 @@ namespace BTCPayServer.Controllers ButtonText = settings.ButtonText, CustomButtonText = settings.CustomButtonText, CustomTipText = settings.CustomTipText, - CustomCSSLink = settings.CustomCSSLink + CustomTipPercentages = settings.CustomTipPercentages, + CustomCSSLink = settings.CustomCSSLink, + AppId = appId }); } diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index b9ebdaecb..74086aba6 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -304,7 +304,7 @@ namespace BTCPayServer.Controllers #pragma warning disable CS0618 // Type or member is obsolete Status = invoice.StatusString, #pragma warning restore CS0618 // Type or member is obsolete - NetworkFee = paymentMethodDetails.GetTxFee(), + NetworkFee = paymentMethodDetails.GetNetworkFee(), IsMultiCurrency = invoice.GetPayments().Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, ChangellyEnabled = changelly != null, ChangellyMerchantId = changelly?.ChangellyMerchantId, diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index e7a0266eb..de58dc171 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -200,8 +200,6 @@ namespace BTCPayServer.Controllers paymentMethod.SetId(supportedPaymentMethod.PaymentId); paymentMethod.Rate = rate.BidAsk.Bid; var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network, preparePayment); - if (storeBlob.NetworkFeeDisabled) - paymentDetails.SetNoTxFee(); paymentMethod.SetPaymentMethodDetails(paymentDetails); Func compare = null; @@ -241,7 +239,7 @@ namespace BTCPayServer.Controllers #pragma warning disable CS0618 if (paymentMethod.GetId().IsBTCOnChain) { - entity.TxFee = paymentMethod.TxFee; + entity.TxFee = paymentMethod.NetworkFee; entity.Rate = paymentMethod.Rate; entity.DepositAddress = paymentMethod.DepositAddress; } diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index 58e609b06..05eea079c 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -46,7 +46,7 @@ namespace BTCPayServer.Controllers string storeId, string cryptoCode, string command, - int account = 0) + string keyPath = "") { if (!HttpContext.WebSockets.IsWebSocketRequest) return NotFound(); @@ -67,7 +67,10 @@ namespace BTCPayServer.Controllers } if (command == "getxpub") { - var getxpubResult = await hw.GetExtPubKey(network, account, normalOperationTimeout.Token); + var k = KeyPath.Parse(keyPath); + if (k.Indexes.Length == 0) + throw new FormatException("Invalid key path"); + var getxpubResult = await hw.GetExtPubKey(network, k, normalOperationTimeout.Token); result = getxpubResult; } } @@ -171,7 +174,8 @@ namespace BTCPayServer.Controllers if (strategy != null) await wallet.TrackAsync(strategy.DerivationStrategyBase); store.SetSupportedPaymentMethod(paymentMethodId, strategy); - storeBlob.SetExcluded(paymentMethodId, willBeExcluded); + storeBlob.SetExcluded(paymentMethodId, willBeExcluded); + storeBlob.SetWalletKeyPathRoot(paymentMethodId, vm.KeyPath == null ? null : KeyPath.Parse(vm.KeyPath)); store.SetStoreBlob(storeBlob); } catch diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 00e812ea0..23aa50234 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -406,7 +406,7 @@ namespace BTCPayServer.Controllers vm.Id = store.Id; vm.StoreName = store.StoreName; vm.StoreWebsite = store.StoreWebsite; - vm.NetworkFee = !storeBlob.NetworkFeeDisabled; + vm.NetworkFeeMode = storeBlob.NetworkFeeMode; vm.AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice; vm.SpeedPolicy = store.SpeedPolicy; vm.CanDelete = _Repo.CanDeleteStores(); @@ -489,7 +489,7 @@ namespace BTCPayServer.Controllers var blob = StoreData.GetStoreBlob(); blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice; - blob.NetworkFeeDisabled = !model.NetworkFee; + blob.NetworkFeeMode = model.NetworkFeeMode; blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index aa0290cd8..bda65045b 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -410,8 +410,8 @@ namespace BTCPayServer.Controllers return NotFound(); var cryptoCode = walletId.CryptoCode; - var storeBlob = (await Repository.FindStore(walletId.StoreId, GetUserId())); - var derivationScheme = GetPaymentMethod(walletId, storeBlob).DerivationStrategyBase; + var storeData = (await Repository.FindStore(walletId.StoreId, GetUserId())); + var derivationScheme = GetPaymentMethod(walletId, storeData).DerivationStrategyBase; var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); @@ -476,15 +476,6 @@ namespace BTCPayServer.Controllers } catch { throw new FormatException("Invalid value for subtract fees"); } } - if (command == "getinfo") - { - var strategy = GetDirectDerivationStrategy(derivationScheme); - if (strategy == null || await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token) == null) - { - throw new Exception($"This store is not configured to use this ledger"); - } - result = new GetInfoResult(); - } if (command == "test") { result = await hw.Test(normalOperationTimeout.Token); @@ -514,10 +505,20 @@ namespace BTCPayServer.Controllers throw new ArgumentOutOfRangeException(nameof(element.amount), "The amount should be above zero"); } - var foundKeyPath = await hw.GetKeyPath(network, strategy, normalOperationTimeout.Token); - if (foundKeyPath == null) + var storeBlob = storeData.GetStoreBlob(); + var paymentId = new Payments.PaymentMethodId(cryptoCode, Payments.PaymentTypes.BTCLike); + var foundKeyPath = storeBlob.GetWalletKeyPathRoot(paymentId); + // Some deployment have the wallet root key path saved in the store blob + // If it does, we only have to make 1 call to the hw to check if it can sign the given strategy, + if (foundKeyPath == null || !await hw.CanSign(network, strategy, foundKeyPath, normalOperationTimeout.Token)) { - throw new HardwareWalletException($"This store is not configured to use this ledger"); + // If the saved wallet key path is not present or incorrect, let's scan the wallet to see if it can sign strategy + foundKeyPath = await hw.FindKeyPath(network, strategy, normalOperationTimeout.Token); + if (foundKeyPath == null) + throw new HardwareWalletException($"This store is not configured to use this ledger"); + storeBlob.SetWalletKeyPathRoot(paymentId, foundKeyPath); + storeData.SetStoreBlob(storeBlob); + await Repository.UpdateStore(storeData); } TransactionBuilder builder = network.NBitcoinNetwork.CreateTransactionBuilder(); diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index ab2955047..29347ed7a 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -249,6 +249,12 @@ namespace BTCPayServer.Data } } + public enum NetworkFeeMode + { + MultiplePaymentsOnly, + Always, + Never + } public class StoreBlob { public StoreBlob() @@ -258,10 +264,21 @@ namespace BTCPayServer.Data PaymentTolerance = 0; RequiresRefundEmail = true; } - public bool NetworkFeeDisabled + + [Obsolete("Use NetworkFeeMode instead")] + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public bool? NetworkFeeDisabled { get; set; } + + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public NetworkFeeMode NetworkFeeMode + { + get; + set; + } + public bool RequiresRefundEmail { get; set; } public string DefaultLang { get; set; } @@ -366,6 +383,23 @@ namespace BTCPayServer.Data [Obsolete("Use GetExcludedPaymentMethods instead")] public string[] ExcludedPaymentMethods { get; set; } +#pragma warning disable CS0618 // Type or member is obsolete + public void SetWalletKeyPathRoot(PaymentMethodId paymentMethodId, KeyPath keyPath) + { + if (keyPath == null) + WalletKeyPathRoots.Remove(paymentMethodId.ToString()); + else + WalletKeyPathRoots.AddOrReplace(paymentMethodId.ToString().ToLowerInvariant(), keyPath.ToString()); + } + public KeyPath GetWalletKeyPathRoot(PaymentMethodId paymentMethodId) + { + if (WalletKeyPathRoots.TryGetValue(paymentMethodId.ToString().ToLowerInvariant(), out var k)) + return KeyPath.Parse(k); + return null; + } +#pragma warning restore CS0618 // Type or member is obsolete + [Obsolete("Use SetWalletKeyPathRoot/GetWalletKeyPathRoot instead")] + public Dictionary WalletKeyPathRoots { get; set; } = new Dictionary(); public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index b9f50431b..f008ec059 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -168,6 +168,13 @@ namespace BTCPayServer request.Path.ToUriComponent()); } + /// + /// If 'toto' and RootPath is 'rootpath' returns '/rootpath/toto' + /// If 'toto' and RootPath is empty returns '/toto' + /// + /// + /// + /// public static string GetRelativePath(this HttpRequest request, string path) { if (path.Length > 0 && path[0] != '/') @@ -177,6 +184,25 @@ namespace BTCPayServer path); } + /// + /// If 'https://example.com/toto' returns 'https://example.com/toto' + /// If 'toto' and RootPath is 'rootpath' returns '/rootpath/toto' + /// If 'toto' and RootPath is empty returns '/toto' + /// + /// + /// + /// + public static string GetRelativePathOrAbsolute(this HttpRequest request, string path) + { + if (Uri.TryCreate(path, UriKind.Absolute, out var unused)) + return path; + if (path.Length > 0 && path[0] != '/') + path = $"/{path}"; + return string.Concat( + request.PathBase.ToUriComponent(), + path); + } + public static string GetAbsoluteUri(this HttpRequest request, string redirectUrl) { bool isRelative = diff --git a/BTCPayServer/HostedServices/MigratorHostedService.cs b/BTCPayServer/HostedServices/MigratorHostedService.cs index 6861a51a7..8afcc70c3 100644 --- a/BTCPayServer/HostedServices/MigratorHostedService.cs +++ b/BTCPayServer/HostedServices/MigratorHostedService.cs @@ -59,6 +59,12 @@ namespace BTCPayServer.HostedServices settings.ConvertMultiplierToSpread = true; await _Settings.UpdateSetting(settings); } + if (!settings.ConvertNetworkFeeProperty) + { + await ConvertNetworkFeeProperty(); + settings.ConvertNetworkFeeProperty = true; + await _Settings.UpdateSetting(settings); + } } catch (Exception ex) { @@ -67,6 +73,26 @@ namespace BTCPayServer.HostedServices } } + private async Task ConvertNetworkFeeProperty() + { + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var store in await ctx.Stores.ToArrayAsync()) + { + var blob = store.GetStoreBlob(); +#pragma warning disable CS0618 // Type or member is obsolete + if (blob.NetworkFeeDisabled != null) + { + blob.NetworkFeeMode = blob.NetworkFeeDisabled.Value ? NetworkFeeMode.Never : NetworkFeeMode.Always; + blob.NetworkFeeDisabled = null; + store.SetStoreBlob(blob); + } +#pragma warning restore CS0618 // Type or member is obsolete + } + await ctx.SaveChangesAsync(); + } + } + private async Task ConvertMultiplierToSpread() { using (var ctx = _DBContextFactory.CreateContext()) diff --git a/BTCPayServer/Models/AppViewModels/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Models/AppViewModels/UpdatePointOfSaleViewModel.cs index 6b9964a7e..5a9a3f8ed 100644 --- a/BTCPayServer/Models/AppViewModels/UpdatePointOfSaleViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/UpdatePointOfSaleViewModel.cs @@ -36,8 +36,11 @@ namespace BTCPayServer.Models.AppViewModels public string CustomButtonText { get; set; } [Required] [MaxLength(30)] - [Display(Name = "Do you want to leave a tip?")] + [Display(Name = "Text to display in the tip input")] public string CustomTipText { get; set; } + [MaxLength(30)] + [Display(Name = "Tip percentage amounts (comma separated)")] + public string CustomTipPercentages { get; set; } [MaxLength(500)] [Display(Name = "Custom bootstrap CSS file")] diff --git a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs index 47fc3630d..802d74a9c 100644 --- a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs @@ -45,6 +45,7 @@ namespace BTCPayServer.Models.AppViewModels public string ButtonText { get; set; } public string CustomButtonText { get; set; } public string CustomTipText { get; set; } + public int[] CustomTipPercentages { get; set; } public string CustomCSSLink { get; set; } } diff --git a/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs b/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs index ffa1c8448..0353c3601 100644 --- a/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/DerivationSchemeViewModel.cs @@ -24,6 +24,7 @@ namespace BTCPayServer.Models.StoreViewModels } = new List<(string KeyPath, string Address)>(); public string CryptoCode { get; set; } + public string KeyPath { get; set; } [Display(Name = "Hint address")] public string HintAddress { get; set; } public bool Confirmation { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 77219cc03..b6c55b6a4 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -82,8 +82,8 @@ namespace BTCPayServer.Models.StoreViewModels get; set; } - [Display(Name = "Add network fee to invoice (vary with mining fees)")] - public bool NetworkFee + [Display(Name = "Add additional fee (network fee) to invoice...")] + public Data.NetworkFeeMode NetworkFeeMode { get; set; } diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index d283a5458..4cc0d91af 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -20,27 +20,21 @@ namespace BTCPayServer.Payments.Bitcoin return DepositAddress; } - public decimal GetTxFee() + public decimal GetNetworkFee() { - return TxFee.ToDecimal(MoneyUnit.BTC); + return NetworkFee.ToDecimal(MoneyUnit.BTC); } - - public void SetNoTxFee() - { - TxFee = Money.Zero; - } - - public void SetPaymentDestination(string newPaymentDestination) { DepositAddress = newPaymentDestination; } + public Data.NetworkFeeMode NetworkFeeMode { get; set; } // Those properties are JsonIgnore because their data is inside CryptoData class for legacy reason [JsonIgnore] public FeeRate FeeRate { get; set; } [JsonIgnore] - public Money TxFee { get; set; } + public Money NetworkFee { get; set; } [JsonIgnore] public String DepositAddress { get; set; } public BitcoinAddress GetDepositAddress(Network network) diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index d31c0a52e..2f1f236fb 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -32,6 +32,7 @@ namespace BTCPayServer.Payments.Bitcoin public TxOut Output { get; set; } public int ConfirmationCount { get; set; } public bool RBF { get; set; } + public decimal NetworkFee { get; set; } /// /// This is set to true if the payment was created before CryptoPaymentData existed in BTCPayServer diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index f38bd84f6..758fdc5fa 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -48,8 +48,18 @@ namespace BTCPayServer.Payments.Bitcoin throw new PaymentMethodUnavailableException($"Full node not available"); var prepare = (Prepare)preparePaymentObject; Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod onchainMethod = new Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod(); + onchainMethod.NetworkFeeMode = store.GetStoreBlob().NetworkFeeMode; onchainMethod.FeeRate = await prepare.GetFeeRate; - onchainMethod.TxFee = onchainMethod.FeeRate.GetFee(100); // assume price for 100 bytes + switch (onchainMethod.NetworkFeeMode) + { + case NetworkFeeMode.Always: + onchainMethod.NetworkFee = onchainMethod.FeeRate.GetFee(100); // assume price for 100 bytes + break; + case NetworkFeeMode.Never: + case NetworkFeeMode.MultiplePaymentsOnly: + onchainMethod.NetworkFee = Money.Zero; + break; + } onchainMethod.DepositAddress = (await prepare.ReserveAddress).ToString(); return onchainMethod; } diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index ca0860629..ad8acabc4 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -158,7 +158,7 @@ namespace BTCPayServer.Payments.Bitcoin var alreadyExist = GetAllBitcoinPaymentData(invoice).Where(c => c.GetPaymentId() == paymentData.GetPaymentId()).Any(); if (!alreadyExist) { - var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network.CryptoCode); + var payment = await _InvoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, paymentData, network); if(payment != null) await ReceivedPayment(wallet, invoice, payment, evt.DerivationStrategy); } @@ -332,7 +332,7 @@ namespace BTCPayServer.Payments.Bitcoin { var transaction = await wallet.GetTransactionAsync(coin.Coin.Outpoint.Hash); var paymentData = new BitcoinLikePaymentData(coin.Coin, transaction.Transaction.RBF); - var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network.CryptoCode).ConfigureAwait(false); + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.Timestamp, paymentData, network).ConfigureAwait(false); alreadyAccounted.Add(coin.Coin.Outpoint); if (payment != null) { diff --git a/BTCPayServer/Payments/IPaymentMethodDetails.cs b/BTCPayServer/Payments/IPaymentMethodDetails.cs index 9fa165a0e..b6d472b1e 100644 --- a/BTCPayServer/Payments/IPaymentMethodDetails.cs +++ b/BTCPayServer/Payments/IPaymentMethodDetails.cs @@ -21,8 +21,7 @@ namespace BTCPayServer.Payments /// Returns what a merchant would need to pay to cashout this payment /// /// - decimal GetTxFee(); - void SetNoTxFee(); + decimal GetNetworkFee(); /// /// Change the payment destination (internal plumbing) /// diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs index 6d630f3a2..47ed0cc4d 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentData.cs @@ -22,6 +22,8 @@ namespace BTCPayServer.Payments.Lightning return GetPaymentId(); } + public decimal NetworkFee { get; set; } + public string GetPaymentId() { return BOLT11; diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs index 60ba19b13..a396e5ca3 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentMethodDetails.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using NBitcoin; namespace BTCPayServer.Payments.Lightning { @@ -21,15 +22,10 @@ namespace BTCPayServer.Payments.Lightning return PaymentTypes.LightningLike; } - public decimal GetTxFee() + public decimal GetNetworkFee() { return 0.0m; } - - public void SetNoTxFee() - { - } - public void SetPaymentDestination(string newPaymentDestination) { BOLT11 = newPaymentDestination; diff --git a/BTCPayServer/Payments/Lightning/LightningListener.cs b/BTCPayServer/Payments/Lightning/LightningListener.cs index abf56011b..e81e14400 100644 --- a/BTCPayServer/Payments/Lightning/LightningListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningListener.cs @@ -192,7 +192,7 @@ namespace BTCPayServer.Payments.Lightning { BOLT11 = notification.BOLT11, Amount = notification.Amount - }, network.CryptoCode, accounted: true); + }, network, accounted: true); if (payment != null) { var invoice = await _InvoiceRepository.GetInvoice(listenedInvoice.InvoiceId); diff --git a/BTCPayServer/Services/HardwareWalletService.cs b/BTCPayServer/Services/HardwareWalletService.cs index 315e8ad90..cc1a9a7e2 100644 --- a/BTCPayServer/Services/HardwareWalletService.cs +++ b/BTCPayServer/Services/HardwareWalletService.cs @@ -89,20 +89,19 @@ namespace BTCPayServer.Services return new LedgerTestResult() { Success = true }; } - public async Task GetExtPubKey(BTCPayNetwork network, int account, CancellationToken cancellation) + public async Task GetExtPubKey(BTCPayNetwork network, KeyPath keyPath, CancellationToken cancellation) { if (network == null) throw new ArgumentNullException(nameof(network)); var segwit = network.NBitcoinNetwork.Consensus.SupportSegwit; - var path = network.GetRootKeyPath().Derive(account, true); - var pubkey = await GetExtPubKey(Ledger, network, path, false, cancellation); + var pubkey = await GetExtPubKey(Ledger, network, keyPath, false, cancellation); var derivation = new DerivationStrategyFactory(network.NBitcoinNetwork).CreateDirectDerivationStrategy(pubkey, new DerivationStrategyOptions() { P2SH = segwit, Legacy = !segwit }); - return new GetXPubResult() { ExtPubKey = derivation.ToString(), KeyPath = path }; + return new GetXPubResult() { ExtPubKey = derivation.ToString(), KeyPath = keyPath }; } private static async Task GetExtPubKey(LedgerClient ledger, BTCPayNetwork network, KeyPath account, bool onlyChaincode, CancellationToken cancellation) @@ -129,7 +128,13 @@ namespace BTCPayServer.Services } } - public async Task GetKeyPath(BTCPayNetwork network, DirectDerivationStrategy directStrategy, CancellationToken cancellation) + public async Task CanSign(BTCPayNetwork network, DirectDerivationStrategy strategy, KeyPath keyPath, CancellationToken cancellation) + { + var hwKey = await GetExtPubKey(Ledger, network, keyPath, true, cancellation); + return hwKey.ExtPubKey.PubKey == strategy.Root.PubKey; + } + + public async Task FindKeyPath(BTCPayNetwork network, DirectDerivationStrategy directStrategy, CancellationToken cancellation) { List derivations = new List(); if (network.NBitcoinNetwork.Consensus.SupportSegwit) @@ -164,7 +169,17 @@ namespace BTCPayServer.Services KeyPath changeKeyPath, CancellationToken cancellationToken) { - return await Ledger.SignTransactionAsync(signatureRequests, unsigned, changeKeyPath, cancellationToken); + try + { + var signedTransaction = await Ledger.SignTransactionAsync(signatureRequests, unsigned, changeKeyPath, cancellationToken); + if (signedTransaction == null) + throw new Exception("The ledger failed to sign the transaction"); + return signedTransaction; + } + catch (Exception ex) + { + throw new Exception("The ledger failed to sign the transaction", ex); + } } public void Dispose() diff --git a/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs b/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs index 543759ee3..f9ad840f5 100644 --- a/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs +++ b/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs @@ -52,6 +52,8 @@ namespace BTCPayServer.Services.Invoices.Export private IEnumerable convertFromDb(InvoiceEntity invoice) { var exportList = new List(); + + var invoiceDue = invoice.ProductInformation.Price; // in this first version we are only exporting invoices that were paid foreach (var payment in invoice.GetPayments()) { @@ -63,6 +65,8 @@ namespace BTCPayServer.Services.Invoices.Export var pdata = payment.GetCryptoPaymentData(); var pmethod = invoice.GetPaymentMethod(payment.GetPaymentMethodId(), Networks); + var paidAfterNetworkFees = pdata.GetValue() - payment.NetworkFee; + invoiceDue -= paidAfterNetworkFees * pmethod.Rate; var target = new ExportInvoiceHolder { @@ -73,6 +77,13 @@ namespace BTCPayServer.Services.Invoices.Export PaymentType = payment.GetPaymentMethodId().PaymentType == Payments.PaymentTypes.BTCLike ? "OnChain" : "OffChain", Destination = payment.GetCryptoPaymentData().GetDestination(Networks.GetNetwork(cryptoCode)), Paid = pdata.GetValue().ToString(CultureInfo.InvariantCulture), + PaidCurrency = (pdata.GetValue() * pmethod.Rate).ToString(CultureInfo.InvariantCulture), + // Adding NetworkFee because Paid doesn't take into account network fees + // so if fee is 10000 satoshis, customer can essentially send infinite number of tx + // and merchant effectivelly would receive 0 BTC, invoice won't be paid + // while looking just at export you could sum Paid and assume merchant "received payments" + NetworkFee = payment.NetworkFee.ToString(CultureInfo.InvariantCulture), + InvoiceDue = invoiceDue, OrderId = invoice.OrderId, StoreId = invoice.StoreId, InvoiceId = invoice.Id, @@ -112,12 +123,14 @@ namespace BTCPayServer.Services.Invoices.Export public string PaymentId { get; set; } public string Destination { get; set; } public string PaymentType { get; set; } - public string Paid { get; set; } public string CryptoCode { get; set; } + public string Paid { get; set; } + public string NetworkFee { get; set; } public decimal ConversionRate { get; set; } - - public decimal InvoicePrice { get; set; } + public string PaidCurrency { get; set; } public string InvoiceCurrency { get; set; } + public decimal InvoiceDue { get; set; } + public decimal InvoicePrice { get; set; } public string InvoiceItemCode { get; set; } public string InvoiceItemDesc { get; set; } public string InvoiceFullStatus { get; set; } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index d586d27b3..746cdc4d4 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -485,7 +485,7 @@ namespace BTCPayServer.Services.Invoices public PaymentMethodDictionary GetPaymentMethods(BTCPayNetworkProvider networkProvider) { - PaymentMethodDictionary rates = new PaymentMethodDictionary(networkProvider); + PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); var serializer = new Serializer(Dummy); #pragma warning disable CS0618 if (PaymentMethod != null) @@ -499,11 +499,11 @@ namespace BTCPayServer.Services.Invoices r.ParentEntity = this; r.Network = networkProvider?.GetNetwork(r.CryptoCode); if (r.Network != null || networkProvider == null) - rates.Add(r); + paymentMethods.Add(r); } } #pragma warning restore CS0618 - return rates; + return paymentMethods; } Network Dummy = Network.Main; @@ -517,8 +517,6 @@ namespace BTCPayServer.Services.Invoices public void SetPaymentMethods(PaymentMethodDictionary paymentMethods) { - if (paymentMethods.NetworkProvider != null) - throw new InvalidOperationException($"{nameof(paymentMethods)} should have NetworkProvider to null"); var obj = new JObject(); var serializer = new Serializer(Dummy); #pragma warning disable CS0618 @@ -681,7 +679,7 @@ namespace BTCPayServer.Services.Invoices /// public Money NetworkFee { get; set; } /// - /// Minimum required to be paid in order to accept invocie as paid + /// Minimum required to be paid in order to accept invoice as paid /// public Money MinimumTotalDue { get; set; } } @@ -731,7 +729,7 @@ namespace BTCPayServer.Services.Invoices { FeeRate = FeeRate, DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress, - TxFee = TxFee + NetworkFee = NetworkFee }; } else @@ -739,7 +737,7 @@ namespace BTCPayServer.Services.Invoices var details = PaymentMethodExtensions.DeserializePaymentMethodDetails(GetId(), PaymentMethodDetails); if (details is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod btcLike) { - btcLike.TxFee = TxFee; + btcLike.NetworkFee = NetworkFee; btcLike.DepositAddress = string.IsNullOrEmpty(DepositAddress) ? null : DepositAddress; btcLike.FeeRate = FeeRate; } @@ -761,7 +759,7 @@ namespace BTCPayServer.Services.Invoices if (paymentMethod is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod bitcoinPaymentMethod) { - TxFee = bitcoinPaymentMethod.TxFee; + NetworkFee = bitcoinPaymentMethod.NetworkFee; FeeRate = bitcoinPaymentMethod.FeeRate; DepositAddress = bitcoinPaymentMethod.DepositAddress; } @@ -777,7 +775,7 @@ namespace BTCPayServer.Services.Invoices public FeeRate FeeRate { get; set; } [JsonProperty(PropertyName = "txFee")] [Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).TxFee")] - public Money TxFee { get; set; } + public Money NetworkFee { get; set; } [JsonProperty(PropertyName = "depositAddress")] [Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).DepositAddress")] public string DepositAddress { get; set; } @@ -801,7 +799,7 @@ namespace BTCPayServer.Services.Invoices .OrderBy(p => p.ReceivedTime) .Select(_ => { - var txFee = _.GetValue(paymentMethods, GetId(), paymentMethods[_.GetPaymentMethodId()].GetTxFee()); + var txFee = _.GetValue(paymentMethods, GetId(), _.NetworkFee); paid += _.GetValue(paymentMethods, GetId()); if (!paidEnough) { @@ -842,17 +840,18 @@ namespace BTCPayServer.Services.Invoices var method = GetPaymentMethodDetails(); if (method == null) return 0.0m; - return method.GetTxFee(); + return method.GetNetworkFee(); } } public class PaymentEntity { + public int Version { get; set; } public DateTimeOffset ReceivedTime { get; set; } - + public decimal NetworkFee { get; set; } [Obsolete("Use ((BitcoinLikePaymentData)GetCryptoPaymentData()).Outpoint")] public OutPoint Outpoint { @@ -889,7 +888,7 @@ namespace BTCPayServer.Services.Invoices #pragma warning disable CS0618 if (string.IsNullOrEmpty(CryptoPaymentDataType)) { - // In case this is a payment done before this update, consider it unconfirmed with RBF for safety + // For invoices created when CryptoPaymentDataType was not existing, we just consider that it is a RBFed payment for safety var paymentData = new Payments.Bitcoin.BitcoinLikePaymentData(); paymentData.Outpoint = Outpoint; paymentData.Output = Output; diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 5bbaccb6e..1ae4befc7 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -41,7 +41,7 @@ namespace BTCPayServer.Services.Invoices public InvoiceRepository(ApplicationDbContextFactory contextFactory, string dbreezePath) { int retryCount = 0; - retry: +retry: try { _Engine = new DBreezeEngine(dbreezePath); @@ -193,7 +193,7 @@ namespace BTCPayServer.Services.Invoices return paymentMethod.GetPaymentMethodDetails().GetPaymentDestination(); } - public async Task NewAddress(string invoiceId, IPaymentMethodDetails paymentMethod, BTCPayNetwork network) + public async Task NewAddress(string invoiceId, Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod paymentMethod, BTCPayNetwork network) { using (var context = _ContextFactory.CreateContext()) { @@ -206,14 +206,13 @@ namespace BTCPayServer.Services.Invoices if (currencyData == null) return false; - var existingPaymentMethod = currencyData.GetPaymentMethodDetails(); + var existingPaymentMethod = (Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod)currencyData.GetPaymentMethodDetails(); if (existingPaymentMethod.GetPaymentDestination() != null) { MarkUnassigned(invoiceId, invoiceEntity, context, currencyData.GetId()); } existingPaymentMethod.SetPaymentDestination(paymentMethod.GetPaymentDestination()); - currencyData.SetPaymentMethodDetails(existingPaymentMethod); #pragma warning disable CS0618 if (network.IsBTC) @@ -379,13 +378,27 @@ namespace BTCPayServer.Services.Invoices private InvoiceEntity ToEntity(Data.InvoiceData invoice) { var entity = ToObject(invoice.Blob, null); + PaymentMethodDictionary paymentMethods = null; #pragma warning disable CS0618 entity.Payments = invoice.Payments.Select(p => { var paymentEntity = ToObject(p.Blob, null); paymentEntity.Accounted = p.Accounted; + + // PaymentEntity on version 0 does not have their own fee, because it was assumed that the payment method have fixed fee. + // We want to hide this legacy detail in InvoiceRepository, so we fetch the fee from the PaymentMethod and assign it to the PaymentEntity. + if (paymentEntity.Version == 0) + { + if (paymentMethods == null) + paymentMethods = entity.GetPaymentMethods(null); + var paymentMethodDetails = paymentMethods.TryGet(paymentEntity.GetPaymentMethodId())?.GetPaymentMethodDetails(); + if (paymentMethodDetails != null) // == null should never happen, but we never know. + paymentEntity.NetworkFee = paymentMethodDetails.GetNetworkFee(); + } + return paymentEntity; - }).ToList(); + }) + .OrderBy(a => a.ReceivedTime).ToList(); #pragma warning restore CS0618 var state = invoice.GetInvoiceState(); entity.ExceptionStatus = state.ExceptionStatus; @@ -546,21 +559,37 @@ namespace BTCPayServer.Services.Invoices /// /// /// The PaymentEntity or null if already added - public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode, bool accounted = false) + public async Task AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, BTCPayNetwork network, bool accounted = false) { using (var context = _ContextFactory.CreateContext()) { + var invoice = context.Invoices.Find(invoiceId); + if (invoice == null) + return null; + InvoiceEntity invoiceEntity = ToObject(invoice.Blob, network.NBitcoinNetwork); + PaymentMethod paymentMethod = invoiceEntity.GetPaymentMethod(new PaymentMethodId(network.CryptoCode, paymentData.GetPaymentType()), null); + IPaymentMethodDetails paymentMethodDetails = paymentMethod.GetPaymentMethodDetails(); PaymentEntity entity = new PaymentEntity { + Version = 1, #pragma warning disable CS0618 - CryptoCode = cryptoCode, + CryptoCode = network.CryptoCode, #pragma warning restore CS0618 ReceivedTime = date.UtcDateTime, - Accounted = accounted + Accounted = accounted, + NetworkFee = paymentMethodDetails.GetNetworkFee() }; entity.SetCryptoPaymentData(paymentData); - + if (paymentMethodDetails is Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod bitcoinPaymentMethod && + bitcoinPaymentMethod.NetworkFeeMode == NetworkFeeMode.MultiplePaymentsOnly && + bitcoinPaymentMethod.NetworkFee == Money.Zero) + { + bitcoinPaymentMethod.NetworkFee = bitcoinPaymentMethod.FeeRate.GetFee(100); // assume price for 100 bytes + paymentMethod.SetPaymentMethodDetails(bitcoinPaymentMethod); + invoiceEntity.SetPaymentMethod(paymentMethod); + invoice.Blob = ToBytes(invoiceEntity, network.NBitcoinNetwork); + } PaymentData data = new PaymentData { Id = paymentData.GetPaymentId(), diff --git a/BTCPayServer/Services/Invoices/PaymentMethodDictionary.cs b/BTCPayServer/Services/Invoices/PaymentMethodDictionary.cs index e5c407f5a..05904588a 100644 --- a/BTCPayServer/Services/Invoices/PaymentMethodDictionary.cs +++ b/BTCPayServer/Services/Invoices/PaymentMethodDictionary.cs @@ -15,13 +15,6 @@ namespace BTCPayServer.Services.Invoices } - public PaymentMethodDictionary(BTCPayNetworkProvider networkProvider) - { - NetworkProvider = networkProvider; - } - - - public BTCPayNetworkProvider NetworkProvider { get; set; } public PaymentMethod this[PaymentMethodId index] { get diff --git a/BTCPayServer/Services/MigrationSettings.cs b/BTCPayServer/Services/MigrationSettings.cs index 93486d06e..1d0d7ccc5 100644 --- a/BTCPayServer/Services/MigrationSettings.cs +++ b/BTCPayServer/Services/MigrationSettings.cs @@ -10,5 +10,6 @@ namespace BTCPayServer.Services public bool UnreachableStoreCheck { get; set; } public bool DeprecatedLightningConnectionStringCheck { get; set; } public bool ConvertMultiplierToSpread { get; set; } + public bool ConvertNetworkFeeProperty { get; set; } } } diff --git a/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml index be9f81fda..654935726 100644 --- a/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml @@ -72,6 +72,11 @@ +
+ + + +
diff --git a/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml b/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml index 12735bc3f..52e3c41c5 100644 --- a/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml +++ b/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml @@ -5,6 +5,7 @@ @{ ViewData["Title"] = Model.Title; Layout = null; + int[] CustomTipPercentages = Model.CustomTipPercentages; } @@ -14,6 +15,11 @@ + + + + + @if (Model.CustomCSSLink != null) { @@ -23,125 +29,330 @@ @if (Model.EnableShoppingCart) { + } + + + + + + + + + + + + + @if (Model.EnableShoppingCart) { -