Merge remote-tracking branch 'upstream/master' into 455-disablereg

This commit is contained in:
Aaron Clauson
2019-01-06 10:05:33 +01:00
61 changed files with 13374 additions and 7530 deletions

View File

@@ -34,6 +34,7 @@ using System.Security.Principal;
using System.Text;
using System.Threading;
using Xunit;
using BTCPayServer.Services;
namespace BTCPayServer.Tests
{

View File

@@ -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<bool> _Evt = null;
IWebHost _Host = null;
CancellationTokenSource _Closed = new CancellationTokenSource();
Channel<JObject> _Requests = Channel.CreateUnbounded<JObject>();
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<JObject>(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<IServerAddressesFeature>().Addresses.First());
}
Action<HttpContext> _Act;
public void ProcessNextRequest(Action<HttpContext> act)
public async Task<JObject> GetNextRequest()
{
var source = new TaskCompletionSource<bool>();
CancellationTokenSource cancellation = new CancellationTokenSource(20000);
cancellation.Token.Register(() => source.TrySetCanceled());
source = new TaskCompletionSource<bool>();
_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");
}
}
}

View File

@@ -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<string> Stores { get; internal set; } = new List<string>();
public void Dispose()

View File

@@ -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<StoreViewModel> modify)
{
var storeController = GetController<StoresController>();
StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model;
modify(store);
storeController.UpdateStore(store).GetAwaiter().GetResult();
}
public T GetController<T>(bool setImplicitStore = true) where T : Controller
{
return parent.PayTester.GetController<T>(UserId, setImplicitStore ? StoreId : null);
@@ -83,10 +99,6 @@ namespace BTCPayServer.Tests
var store = parent.PayTester.GetController<StoresController>(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(),

View File

@@ -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<PaymentEntity>();
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<PaymentEntity>();
@@ -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<PaymentEntity>();
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<InvoicePaymentNotification>(ipn); //can deserialize
});
var request = await callbackServer.GetNextRequest();
if (request.ContainsKey("event"))
{
var evtName = request["event"]["name"].Value<string>();
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<RedirectToActionResult>(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<InvoiceController>().Export("json").GetAwaiter().GetResult();
var result = Assert.IsType<ContentResult>(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<InvoiceController>().Export("json").GetAwaiter().GetResult();
var paidresult = Assert.IsType<ContentResult>(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<object[]>(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<NetworkFeeMode>())
{
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<ContentResult>(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);
});
}
}

View File

@@ -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

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
<Version>1.0.3.33</Version>
<Version>1.0.3.36</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<PropertyGroup>

View File

@@ -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<int>();
}
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();
}
}
}
}

View File

@@ -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
});
}

View File

@@ -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,

View File

@@ -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<Money, Money, bool> 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;
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<string, string> WalletKeyPathRoots { get; set; } = new Dictionary<string, string>();
public IPaymentFilter GetExcludedPaymentMethods()
{

View File

@@ -168,6 +168,13 @@ namespace BTCPayServer
request.Path.ToUriComponent());
}
/// <summary>
/// If 'toto' and RootPath is 'rootpath' returns '/rootpath/toto'
/// If 'toto' and RootPath is empty returns '/toto'
/// </summary>
/// <param name="request"></param>
/// <param name="path"></param>
/// <returns></returns>
public static string GetRelativePath(this HttpRequest request, string path)
{
if (path.Length > 0 && path[0] != '/')
@@ -177,6 +184,25 @@ namespace BTCPayServer
path);
}
/// <summary>
/// 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'
/// </summary>
/// <param name="request"></param>
/// <param name="path"></param>
/// <returns></returns>
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 =

View File

@@ -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())

View File

@@ -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")]

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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; }
/// <summary>
/// This is set to true if the payment was created before CryptoPaymentData existed in BTCPayServer

View File

@@ -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;
}

View File

@@ -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)
{

View File

@@ -21,8 +21,7 @@ namespace BTCPayServer.Payments
/// Returns what a merchant would need to pay to cashout this payment
/// </summary>
/// <returns></returns>
decimal GetTxFee();
void SetNoTxFee();
decimal GetNetworkFee();
/// <summary>
/// Change the payment destination (internal plumbing)
/// </summary>

View File

@@ -22,6 +22,8 @@ namespace BTCPayServer.Payments.Lightning
return GetPaymentId();
}
public decimal NetworkFee { get; set; }
public string GetPaymentId()
{
return BOLT11;

View File

@@ -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;

View File

@@ -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);

View File

@@ -89,20 +89,19 @@ namespace BTCPayServer.Services
return new LedgerTestResult() { Success = true };
}
public async Task<GetXPubResult> GetExtPubKey(BTCPayNetwork network, int account, CancellationToken cancellation)
public async Task<GetXPubResult> 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<BitcoinExtPubKey> GetExtPubKey(LedgerClient ledger, BTCPayNetwork network, KeyPath account, bool onlyChaincode, CancellationToken cancellation)
@@ -129,7 +128,13 @@ namespace BTCPayServer.Services
}
}
public async Task<KeyPath> GetKeyPath(BTCPayNetwork network, DirectDerivationStrategy directStrategy, CancellationToken cancellation)
public async Task<bool> 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<KeyPath> FindKeyPath(BTCPayNetwork network, DirectDerivationStrategy directStrategy, CancellationToken cancellation)
{
List<KeyPath> derivations = new List<KeyPath>();
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()

View File

@@ -52,6 +52,8 @@ namespace BTCPayServer.Services.Invoices.Export
private IEnumerable<ExportInvoiceHolder> convertFromDb(InvoiceEntity invoice)
{
var exportList = new List<ExportInvoiceHolder>();
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; }

View File

@@ -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
/// </summary>
public Money NetworkFee { get; set; }
/// <summary>
/// Minimum required to be paid in order to accept invocie as paid
/// Minimum required to be paid in order to accept invoice as paid
/// </summary>
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;

View File

@@ -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<bool> NewAddress(string invoiceId, IPaymentMethodDetails paymentMethod, BTCPayNetwork network)
public async Task<bool> 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<InvoiceEntity>(invoice.Blob, null);
PaymentMethodDictionary paymentMethods = null;
#pragma warning disable CS0618
entity.Payments = invoice.Payments.Select(p =>
{
var paymentEntity = ToObject<PaymentEntity>(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
/// <param name="cryptoCode"></param>
/// <param name="accounted"></param>
/// <returns>The PaymentEntity or null if already added</returns>
public async Task<PaymentEntity> AddPayment(string invoiceId, DateTimeOffset date, CryptoPaymentData paymentData, string cryptoCode, bool accounted = false)
public async Task<PaymentEntity> 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<InvoiceEntity>(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(),

View File

@@ -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

View File

@@ -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; }
}
}

View File

@@ -72,6 +72,11 @@
<input asp-for="CustomTipText" class="form-control" />
<span asp-validation-for="CustomTipText" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CustomTipPercentages" class="control-label"></label>
<input asp-for="CustomTipPercentages" class="form-control" />
<span asp-validation-for="CustomTipPercentages" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CustomCSSLink" class="control-label"></label>
<a href="https://docs.btcpayserver.org/development/theme#bootstrap-themes" target="_blank"><span class="fa fa-question-circle-o" title="More information..."></span></a>

View File

@@ -5,6 +5,7 @@
@{
ViewData["Title"] = Model.Title;
Layout = null;
int[] CustomTipPercentages = Model.CustomTipPercentages;
}
<!DOCTYPE html>
@@ -14,6 +15,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="~/img/icons/icon-512x512.png">
<link rel="apple-touch-startup-image" href="~/img/splash.png">
<link rel="manifest" href="~/manifest.json">
<link href="@this.Context.Request.GetAbsoluteUri(themeManager.BootstrapUri)" rel="stylesheet" />
@if (Model.CustomCSSLink != null)
{
@@ -23,125 +29,330 @@
@if (Model.EnableShoppingCart)
{
<link rel="stylesheet" href="~/cart/css/style.css">
<script type="text/javascript">
var srvModel = @Html.Raw(Json.Serialize(Model));
</script>
<bundle name="wwwroot/bundles/cart-bundle.min.js" />
}
</head>
<script id="template-cart-item" type="text/template">
<tr data-id="{id}">
{image}
<td class="align-middle pr-0 pl-2"><b>{title}</b></td>
<td class="align-middle px-0" align="right">
<a class="js-cart-item-remove btn btn-link" href="#"><i class="fa fa-trash text-muted"></i></a>
</td>
<td class="align-middle px-0" align="right">
<div class="input-group">
<div class="input-group-prepend">
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
</div>
<input class="js-cart-item-count form-control form-control-sm pull-left" type="text"name="count" placeholder="Qty" value="{count}" data-prev="{count}">
<div class="input-group-append"><a class="js-cart-item-plus btn btn-link px-2" href="#">
<i class="fa fa-plus-circle fa-fw text-success"></i></a>
</div>
</div>
</td>
<td class="align-middle" align="right">{price}</td>
</tr>
</script>
<script id="template-cart-item-image" type="text/template">
<td class="align-middle pr-0" width="1%"><img src="{image}" width="50"></td>
</script>
<script id="template-cart-custom-amount" type="text/template">
<tr>
<td colspan="5">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
</div>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Pay what you want">
<div class="input-group-append">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
</td>
</tr>
</script>
<script id="template-cart-extra" type="text/template">
<tr>
<td colspan="5" class="border-0 pb-0">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
</div>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" value="{customAmount}" placeholder="Pay what you want">
<div class="input-group-append">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
</td>
</tr>
<tr>
<td colspan="5" class="border-top-0">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-percent fa-fw"></i></span>
</div>
<input class="js-cart-discount form-control" type="number" min="0" step="@Model.Step" value="{discount}" name="discount" placeholder="Discount in %">
<div class="input-group-append">
<a class="js-cart-discount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
</td>
</tr>
</script>
<script id="template-cart-tip" type="text/template">
<tr class="h5">
<td colspan="5" class="border-top-0 pt-4">@Model.CustomTipText</td>
</tr>
<tr>
<td colspan="5" class="border-0">
<div class="input-group mb-2">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-money fa-fw"></i></span>
</div>
<input class="js-cart-tip form-control" type="number" min="0" step="@Model.Step" value="{tip}" name="tip" placeholder="Tip in @(Model.CurrencyInfo.CurrencySymbol != null ? Model.CurrencyInfo.CurrencySymbol : Model.CurrencyCode)">
<div class="input-group-append">
<a class="js-cart-tip-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="row mb-1">
@if (CustomTipPercentages != null && CustomTipPercentages.Length > 0) {
@for (int i = 0; i < CustomTipPercentages.Length; i++) {
var percentage = CustomTipPercentages[i];
<div class="col">
<a class="js-cart-tip-btn btn btn-light btn-block border mb-2" href="#" data-tip="@percentage">@percentage%</a>
</div>
}
}
</div>
</td>
</tr>
</script>
<script id="template-cart-total" type="text/template">
<tr class="h4 table-light">
<td colspan="1" class="pb-4">Total</td>
<td colspan="4" align="right" class="pb-4">
<span class="js-cart-total">{total}</span>
</td>
</tr>
</script>
<body class="h-100">
@if (Model.EnableShoppingCart)
{
<div id="cartModal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Shopping cart</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<div id="cartModal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white border-0">
<h5 class="modal-title">Confirmation</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true"><i class="fa fa-times fa-fw"></i></span>
</button>
</div>
<div class="modal-body p-0">
<table id="js-cart-summary" class="table m-0">
<tbody class="my-3">
<tr class="h5">
<td colspan="2" class="border-top-0">Summary</td>
</tr>
<tr class="h6">
<td class="border-0 pb-0">Total products</td>
<td align="right" class="border-0 pb-0">
<span class="js-cart-summary-products text-nowrap"></span>
</td>
</tr>
<tr class="h6">
<td class="border-0 pb-y">Discount</td>
<td align="right" class="border-0 pb-y">
<span class="js-cart-summary-discount text-nowrap"></span>
</td>
</tr>
<tr class="h6">
<td class="border-top-0 pt-0">Tip</td>
<td align="right" class="border-top-0 pt-0">
<span class="js-cart-summary-tip text-nowrap"></span>
</td>
</tr>
<tr class="h3 table-light">
<td>Total</td>
<td align="right">
<span class="js-cart-summary-total text-nowrap"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer bg-light">
<form method="post" asp-antiforgery="false" data-buy>
<input id="js-cart-amount" class="form-control" type="hidden" name="amount">
<button id="js-cart-pay" class="btn btn-primary btn-lg" type="submit"><b>@Model.CustomButtonText</b></button>
</form>
</div>
</div>
</div>
<div class="modal-body">
<table id="js-cart-list" class="table mt-2 mb-3">
</div>
<div class="wrapper">
<!-- Page Content -->
<div id="content">
<div class="p-3">
<div class="row">
<div class="col-sm-4 col-lg-3 order-sm-last text-right mb-2">
<a class="js-cart btn btn-warning text-white text-right" href="#"><i class="fa fa-shopping-basket"></i>&nbsp; <span class="badge badge-light badge-pill"><span id="js-cart-items">0</span></span></a>
</div>
<div class="col-sm-8 col-lg-9 mb-2">
<div class="input-group mb-2">
<input type="text" class="js-search form-control" placeholder="Find product">
<a class="js-search-reset btn btn-link text-black" href="#" style="position: absolute;right: 0px; z-index: 1049; display: none;"><i class="fa fa-times-circle fa-lg"></i></a>
</div>
</div>
</div>
</div>
<div id="js-pos-list" class="text-center mx-auto px-4">
<div class="row">
@for (int i = 0; i < Model.Items.Length; i++)
{
var item = Model.Items[i];
var image = item.Image;
var description = item.Description;
<div class="col-sm-6 col-lg-3 my-3 px-2 card-wrapper">
<div class="js-add-cart card" data-id="@i">
@if (!String.IsNullOrWhiteSpace(image))
{
<img class="card-img-top" src="@image" alt="Card image cap">
}
<div class="card-body p-3">
<h6 class="card-title mb-0">@item.Title</h6>
@if (!String.IsNullOrWhiteSpace(description))
{
<p class="card-text">@description</p>
}
<span class="text-muted small">@String.Format(Model.ButtonText, @item.Price.Formatted)</span>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Sidebar -->
<nav id="sidebar" class="bg-dark">
<div class="bg-warning p-3 clearfix">
<h3 class="text-white m-0 pull-left">Cart</h3>
<a class="js-cart btn btn-sm bg-white text-black pull-right ml-5" href="#"><i class="fa fa-times fa-lg"></i></a>
<a class="js-cart-destroy btn btn-sm bg-white text-danger pull-right" href="#" style="display: none;">Empty cart <i class="fa fa-trash fa-fw fa-lg"></i></a>
</div>
<table id="js-cart-list" class="table table-responsive bg-light mt-0 mb-0">
<thead class="thead-dark">
<tr>
<th colspan="2">Product</th>
<th class="text-right" width="80">Quantity</th>
<th class="text-right" width="25%">Price</th>
<th colspan="3" width="55%">Product</th>
<th class="text-center" width="20%"><div style="width: 84px">Quantity</div></th>
<th class="text-right" width="25%"><div style="min-width: 50px">Price</div></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<form method="post" asp-antiforgery="false" data-buy>
<input id="js-cart-amount" class="form-control" type="hidden" name="amount">
<button id="js-cart-pay" class="btn btn-primary" type="submit"><b>@Model.CustomButtonText</b></button>
</form>
</div>
</div>
</div>
</div>
}
<div class="container d-flex h-100">
<div class="justify-content-center align-self-center text-center mx-auto px-2 py-3 w-100" style="margin: auto;">
<h1 class="mb-4">@Model.Title</h1>
@if (Model.EnableShoppingCart)
{
<a id="js-cart" class="btn btn-warning text-white text-right" href="#" data-toggle="modal" data-target="#cartModal"><i class="fa fa-shopping-basket"></i>&nbsp; <span class="badge badge-light badge-pill"><span id="js-cart-items">0</span></span></a>
}
<div class="row">
@for (int i = 0; i < Model.Items.Length; i++)
{
var className = (Model.Items.Length - i) > (Model.Items.Length % 4) ? "col-sm-6 col-lg-3" : "col-md align-self-start";
var item = Model.Items[i];
var image = item.Image;
var description = item.Description;
<div class="@className my-3 px-2">
<div class="card" data-id="@i">
@if (!String.IsNullOrWhiteSpace(image))
{
<img class="card-img-top" src="@image" alt="Card image cap">
}
<div class="card-body">
<h5 class="card-title">@item.Title</h5>
@if (!String.IsNullOrWhiteSpace(description))
<table id="js-cart-extra" class="table bg-light mt-0 mb-0">
<tbody></tbody>
</table>
<button id="js-cart-confirm" data-toggle="modal" data-target="#cartModal" class="btn btn-primary btn-lg btn-block mb-3 p-3" disabled="disabled" type="submit"><b>Confirm</b></button>
<div class="text-center mb-5 pb-5">
<img src="~/img/logo-white.png" height="40">
</div>
</nav>
</div>
} else {
<div class="container d-flex h-100">
<div class="justify-content-center align-self-center text-center mx-auto px-2 py-3 w-100" style="margin: auto;">
<h1 class="mb-4">@Model.Title</h1>
<div class="row">
@for (int i = 0; i < Model.Items.Length; i++)
{
var className = (Model.Items.Length - i) > (Model.Items.Length % 4) ? "col-sm-6 col-lg-3" : "col-md align-self-start";
var item = Model.Items[i];
var image = item.Image;
var description = item.Description;
<div class="@className my-3 px-2">
<div class="card" data-id="@i">
@if (!String.IsNullOrWhiteSpace(image))
{
<p class="card-text">@description</p>
<img class="card-img-top" src="@image" alt="Card image cap">
}
@if (item.Custom && !Model.EnableShoppingCart)
{
<div class="card-body">
<h5 class="card-title">@item.Title</h5>
@if (!String.IsNullOrWhiteSpace(description))
{
<p class="card-text">@description</p>
}
@if (item.Custom && !Model.EnableShoppingCart)
{
<form method="post" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id" />
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">@Model.CurrencySymbol</span>
</div>
<input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount"
value="@item.Price.Value" placeholder="Amount">
<div class="input-group-append">
<button class="btn btn-primary" type="submit">@Model.CustomButtonText</button>
</div>
</div>
</form>
}
else
{
<form method="post" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@String.Format(Model.ButtonText, @item.Price.Formatted)</button>
</form>
}
</div>
</div>
</div>
}
</div>
@if (Model.ShowCustomAmount)
{
<div class="row mt-2 mb-4">
<div class="col-lg-4 offset-lg-4 col-md-6 offset-md-3 px-2">
<div class="card">
<div class="card-body">
<h5 class="card-title">Custom Amount</h5>
<p class="card-text">Create invoice to pay custom amount</p>
<form method="post" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id" />
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">@Model.CurrencySymbol</span>
</div>
<input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount"
value="@item.Price.Value" placeholder="Amount">
<div class="input-group-append">
<button class="btn btn-primary" type="submit">@Model.CustomButtonText</button>
</div>
<input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Amount">
<div class="input-group-append"><button class="btn btn-primary" type="submit">@Model.CustomButtonText</button></div>
</div>
</form>
}
else
{
<form method="post" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@String.Format(Model.ButtonText, @item.Price.Formatted)</button>
</form>
}
</div>
</div>
</div>
</div>
}
</div>
@if (Model.ShowCustomAmount)
{
<div class="row mt-2 mb-4">
<div class="col-lg-4 offset-lg-4 col-md-6 offset-md-3 px-2">
<div class="card">
<div class="card-body">
<h5 class="card-title">Custom Amount</h5>
<p class="card-text">Create invoice to pay custom amount</p>
<form method="post" asp-antiforgery="false" data-buy>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">@Model.CurrencySymbol</span>
</div>
<input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Amount">
<div class="input-group-append"><button class="btn btn-primary" type="submit">@Model.CustomButtonText</button></div>
</div>
</form>
</div>
</div>
</div>
</div>
}
</div>
</div>
}
</body>
</html>

View File

@@ -19,8 +19,8 @@
<title>BTCPay Server</title>
@* CSS *@
<link href="@this.Context.Request.GetAbsoluteUri(themeManager.BootstrapUri)" rel="stylesheet" />
<link href="@this.Context.Request.GetAbsoluteUri(themeManager.CreativeStartUri)" rel="stylesheet" />
<link href="@this.Context.Request.GetRelativePathOrAbsolute(themeManager.BootstrapUri)" rel="stylesheet" />
<link href="@this.Context.Request.GetRelativePathOrAbsolute(themeManager.CreativeStartUri)" rel="stylesheet" />
<bundle name="wwwroot/bundles/main-bundle.min.css" />

View File

@@ -22,6 +22,7 @@
<span>The DerivationScheme represents the destination of the funds received by your invoice. It is generated by your wallet software. Please, verify that you are generating the right addresses by clicking on 'Check ExtPubKey'</span>
</div>
<input id="CryptoCurrency" asp-for="CryptoCode" type="hidden" />
<input id="KeyPath" asp-for="KeyPath" type="hidden" />
<div class="form-group">
<label asp-for="DerivationScheme"></label>
<input asp-for="DerivationScheme" class="form-control" />
@@ -40,7 +41,7 @@
<ul>
@for (int i = 0; i < 4; i++)
{
<li><a class="ledger-info-recommended" data-ledgeraccount="@i" href="#">Account @i (<span>@Model.RootKeyPath.Derive(i, true)</span>)</a></li>
<li><a class="ledger-info-recommended" data-ledgerkeypath="@Model.RootKeyPath.Derive(i, true)" href="#">Account @i (<span>@Model.RootKeyPath.Derive(i, true)</span>)</a></li>
}
</ul>
</div>
@@ -95,6 +96,7 @@
<span>Please check that your @Model.CryptoCode wallet is generating the same addresses as below.</span>
</div>
<input type="hidden" asp-for="Confirmation" />
<input id="KeyPath" asp-for="KeyPath" type="hidden" />
<input type="hidden" asp-for="DerivationScheme" />
<input type="hidden" asp-for="Enabled" />
<div class="form-group">

View File

@@ -43,15 +43,15 @@
<div class="form-group">
<label>Button Size</label>
<div style="vertical-align:top; font-size:12px; display:flex;">
<button class="btn" style="width:95px;height:40px;margin-right:40px;"
<button class="btn" style="width:146px;height:40px;margin-right:40px;"
v-on:click="inputChanges($event, 0)" v-bind:class="{'btn-primary': (srvModel.buttonSize == 0) }">
146 x 40 px
</button>
<button class="btn btn-default" style="width:126px;height:46px;margin-right:40px;"
<button class="btn btn-default" style="width:168px;height:46px;margin-right:40px;"
v-on:click="inputChanges($event, 1)" v-bind:class="{'btn-primary': (srvModel.buttonSize == 1) }">
168 x 46 px
</button>
<button class="btn btn-default" style="width:146px;height:57px;"
<button class="btn btn-default" style="width:209px;height:57px;"
v-on:click="inputChanges($event, 2)" v-bind:class="{'btn-primary': (srvModel.buttonSize == 2) }">
209 x 57 px
</button>

View File

@@ -43,9 +43,13 @@
<span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="NetworkFee"></label>
<label asp-for="NetworkFeeMode"></label>
<a href="https://docs.btcpayserver.org/faq-and-common-issues/faq-stores#add-network-fee-to-invoice-vary-with-mining-fees" target="_blank"><span class="fa fa-question-circle-o" title="More information..."></span></a>
<input asp-for="NetworkFee" type="checkbox" class="form-check" />
<select asp-for="NetworkFeeMode" class="form-control">
<option value="MultiplePaymentsOnly">... only if the customer makes more than one payment for the invoice</option>
<option value="Always">... on every payment</option>
<option value="Never">Never add network fee</option>
</select>
</div>
<div class="form-group">
<label asp-for="AnyoneCanCreateInvoice"></label>

View File

@@ -28,10 +28,6 @@
<p id="hw-loading"><span class="fa fa-question-circle" style="color:orange"></span> <span>Detecting hardware wallet...</span></p>
<p id="hw-error" style="display:none;"><span class="fa fa-times-circle" style="color:red;"></span> <span class="hw-label">An error happened</span></p>
<p id="hw-success" style="display:none;"><span class="fa fa-check-circle" style="color:green;"></span> <span class="hw-label">Detecting hardware wallet...</span></p>
<p id="check-loading" style="display:none;"><span class="fa fa-question-circle" style="color:orange"></span> <span class="check-label">Detecting hardware wallet...</span></p>
<p id="check-error" style="display:none;"><span class="fa fa-times-circle" style="color:red;"></span> <span class="check-label">An error happened</span></p>
<p id="check-success" style="display:none;"><span class="fa fa-check-circle" style="color:green;"></span> <span class="check-label">Detecting hardware wallet...</span></p>
</div>
</div>

View File

@@ -0,0 +1,128 @@
.modal-content {
border-radius: 0.5rem;
}
.modal-header {
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
}
.card-img-top {
width: 100%;
max-height: 180px;
object-fit: cover;
}
.js-cart-added {
background-color: rgba(0, 0, 0, 0.7);
border-radius: 0.25rem;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
}
.js-cart-added .fa {
height: 50px;
position: relative;
top: 50%;
margin-top: -25px;
}
.js-add-cart:hover {
cursor: pointer;
}
#js-cart-confirm {
border-radius: 0;
}
/* ---------------------------------------------------
SIDEBAR STYLE
----------------------------------------------------- */
.wrapper {
display: flex;
width: 100%;
}
#sidebar {
width: 400px;
position: fixed;
top: 0;
right: 0;
height: 100vh;
overflow-x: hidden;
overflow-y: scroll;
z-index: 999;
background: #e1e6ea;
transition: all 0.3s;
-webkit-overflow-scrolling: touch;
}
#sidebar .js-cart {
display: none;
}
#sidebar.active {
margin-right: -400px;
}
/* ---------------------------------------------------
CONTENT STYLE
----------------------------------------------------- */
#content {
width: calc(100% - 400px);
min-height: 100vh;
transition: all 0.3s;
position: absolute;
top: 0;
left: 0;
}
#content.active {
width: 100%;
}
.bg-gray {
background-color: #aaa;
}
.text-black {
color: #000;
}
/* ---------------------------------------------------
MEDIAQUERIES
----------------------------------------------------- */
@media (max-width: 768px) {
#sidebar {
margin-right: -400px;
}
#sidebar .js-cart {
display: inline;
}
#sidebar.active {
margin-right: 0;
}
#content {
width: 100%;
}
#content.active {
width: calc(100% - 400px);
}
#sidebarCollapse span {
display: none;
}
}
@media (max-width: 575px) {
#sidebar {
width: 100%;
margin-right: -575px;
}
#content.active {
width: 100%;
}
}

View File

@@ -1,28 +1,48 @@
$.fn.addAnimate = function(completeCallback) {
var documentHeight = $(document).height(),
itemPos = $(this).offset(),
itemY = itemPos.top,
cartPos = $('#js-cart').find('.badge').position();
tempItem = '<span id="js-cart-temp-item" class="badge badge-primary text-white badge-pill " style="' +
'position: absolute;' +
'top: ' + itemPos.top + 'px;' +
'left: ' + (itemPos.left + 50) + 'px;">'+
'<i class="fa fa-shopping-basket"></i></span>';
if ($(this).find('.js-cart-added').length === 0) {
$(this).append('<div class="js-cart-added"><i class="fa fa-check fa-3x text-white align-middle"></i></div>');
// Animate the element
$(this).find('.js-cart-added').fadeIn(200, function(){
var self = this;
// Show it for 200ms
setTimeout(function(){
// Hide and remove
$(self).fadeOut(100, function(){
$(this).remove();
// Make animation speed look constant regardless of how far the object is from the cart
var animationSpeed = (Math.log(itemY) * (documentHeight / Math.log2(documentHeight - itemY))) / 2;
completeCallback && completeCallback();
})
}, 200);
});
}
}
// Add the cart item badge and animate it
$('body').after(tempItem);
$('#js-cart-temp-item').animate({
easing: 'swing',
top: cartPos.top,
left: cartPos.left
}, animationSpeed, function() {
$(this).remove();
completeCallback && completeCallback();
});
};
function removeAccents(input){
var accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇČçčÐĎďÌÍÎÏìíîïĽľÙÚÛÜùúûüÑŇñňŠšŤťŸÿýŽž ́',
accentsOut = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCCccDDdIIIIiiiiLlUUUUuuuuNNnnSsTtYyyZz ',
output = '',
index = -1;
for( var i = 0; i < input.length; i++ ) {
index = accents.indexOf(input[i]);
if( index != -1 ) {
output += typeof accentsOut[index] != 'undefined' ? accentsOut[index] : '';
}
else {
output += typeof input[i] != 'undefined' ? input[i] : '';
}
}
return output;
}
jQuery.expr[':'].icontains = function (a, i, m) {
var string = removeAccents(jQuery(a).text().toLowerCase());
return string.indexOf(removeAccents(m[3].toLowerCase())) >= 0;
};
$(document).ready(function(){
var cart = new Cart();
@@ -31,27 +51,86 @@ $(document).ready(function(){
event.preventDefault();
var $btn = $(event.target),
self = this;
id = $btn.closest('.card').data('id'),
item = srvModel.items[id];
item = srvModel.items[id],
items = cart.items;
// Is event catching disabled?
if (!$(this).hasClass('disabled')) {
// Disable catching events for this element
$(this).addClass('disabled');
// Add-to-cart animation only once
$(this).addAnimate(function(){
// Enable the event
$(self).removeClass('disabled');
});
// Animate adding and then add then save
$(this).addAnimate(function(){
cart.addItem({
id: id,
title: item.title,
price: item.price,
image: typeof item.image != 'underfined' ? item.image : null
});
});
cart.listItems();
}
});
// Destroy the cart when the "pay button is clicked"
$('#js-cart-pay').click(function(){
cart.destroy();
cart.destroy(true);
});
// Repopulate cart items in the modal when it opens
$('#cartModal').on('show.bs.modal', function () {
cart.listItems();
$('.js-cart').on('click', function () {
$('#sidebar, #content').toggleClass('active');
$('.collapse.in').toggleClass('in');
$('a[aria-expanded=true]').attr('aria-expanded', 'false');
});
$('.js-search').keyup(function(event){
var str = $(this).val();
$('#js-pos-list').find(".card-wrapper").show();
if (str.length > 1) {
var $list = $('#js-pos-list').find(".card-title:not(:icontains('" + str + "'))");
$list.parents('.card-wrapper').hide();
$('.js-search-reset').show();
}
});
$('.js-search-reset').click(function(event){
event.preventDefault();
$('.js-search').val('');
$('.js-search').trigger('keyup');
$(this).hide();
});
$('#js-cart-summary').find('tbody').prepend(cart.template($('#template-cart-tip'), {
'tip': cart.fromCents(cart.getTip()) || ''
}));
$('#cartModal').one('show.bs.modal', function () {
cart.updateDiscount();
cart.updateTip();
cart.updateSummaryProducts();
cart.updateSummaryTotal();
// Change total when tip is changed
$('.js-cart-tip').inputAmount(cart, 'tip');
// Remove tip
$('.js-cart-tip-remove').removeAmount(cart, 'tip');
$('.js-cart-tip-btn').click(function(event){
event.preventDefault();
var $tip = $('.js-cart-tip'),
discount = cart.percentage(cart.getTotalProducts(), cart.getDiscount());
$tip.val(cart.percentage(cart.getTotalProducts() - discount, parseInt($(this).data('tip'))));
$tip.trigger('input');
});
});
});

View File

@@ -2,126 +2,313 @@ function Cart() {
this.items = 0;
this.totalAmount = 0;
this.content = [];
this.tip = 0;
this.loadLocalStorage();
this.itemsCount();
this.buildUI();
this.$list = $('#js-cart-list');
this.$items = $('#js-cart-items');
this.$total = $('.js-cart-total');
this.$summaryProducts = $('.js-cart-summary-products');
this.$summaryDiscount = $('.js-cart-summary-discount');
this.$summaryTotal = $('.js-cart-summary-total');
this.$summaryTip = $('.js-cart-summary-tip');
this.$destroy = $('.js-cart-destroy');
this.$confirm = $('#js-cart-confirm');
this.listItems();
this.bindEmptyCart();
this.updateItemsCount();
this.updateAmount();
}
Cart.prototype.addItem = function(item) {
// Increment the existing item count
var result = this.content.filter(function(obj){
if (obj.id === item.id){
obj.count++;
Cart.prototype.setCustomAmount = function(amount) {
this.customAmount = this.toNumber(amount);
if (this.customAmount > 0) {
localStorage.setItem(this.getStorageKey('cartCustomAmount'), this.customAmount);
} else {
localStorage.removeItem(this.getStorageKey('cartCustomAmount'));
}
return this.customAmount;
}
Cart.prototype.getCustomAmount = function() {
return this.toCents(this.customAmount);
}
Cart.prototype.setTip = function(amount) {
this.tip = this.toNumber(amount);
if (this.tip > 0) {
localStorage.setItem(this.getStorageKey('cartTip'), this.tip);
} else {
localStorage.removeItem(this.getStorageKey('cartTip'));
}
return this.tip;
}
Cart.prototype.getTip = function() {
return this.toCents(this.tip);
}
Cart.prototype.setDiscount = function(amount) {
this.discount = this.toNumber(amount);
if (this.discount > 0) {
localStorage.setItem(this.getStorageKey('cartDiscount'), this.discount);
} else {
localStorage.removeItem(this.getStorageKey('cartDiscount'));
}
return this.discount;
}
Cart.prototype.getDiscount = function() {
return this.toCents(this.discount);
}
Cart.prototype.getDiscountAmount = function(amount) {
return this.percentage(amount, this.getDiscount());
}
// Get total amount of products
Cart.prototype.getTotalProducts = function() {
var amount = 0 ;
// Always calculate the total amount based on the cart content
for (var key in this.content) {
if (
this.content.hasOwnProperty(key) &&
typeof this.content[key] != 'undefined' &&
!this.content[key].disabled
) {
var price = this.toCents(this.content[key].price.value);
amount += (this.content[key].count * price);
}
return obj.id === item.id
});
}
// Add custom amount
amount += this.getCustomAmount();
return amount;
}
// Get absolute total amount
Cart.prototype.getTotal = function(includeTip) {
this.totalAmount = this.getTotalProducts();
if (this.getDiscount() > 0) {
this.totalAmount -= this.getDiscountAmount(this.totalAmount);
}
if (includeTip) {
this.totalAmount += this.getTip();
}
return this.fromCents(this.totalAmount);
}
/*
* Data manipulation
*/
// Add item to the cart or update its count
Cart.prototype.addItem = function(item) {
var id = item.id,
result = this.content.filter(function(obj){
return obj.id === id;
});
// Add new item because it doesn't exist yet
if (!result.length) {
this.content.push({id: item.id, title: item.title, price: item.price, count: 1, image: item.image})
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image});
this.emptyCartToggle();
}
this.items++;
this.saveLocalStorage();
this.itemsCount();
this.updateTotal();
this.updateAmount();
// Increment item count
this.incrementItem(id);
}
Cart.prototype.incrementItem = function(id) {
var self = this;
this.items = 0; // Calculate total # of items from scratch just to make sure
this.content.filter(function(obj){
// Increment the item count
if (obj.id === id){
obj.count++;
delete(obj.disabled);
}
// Increment the total # of items
self.items += obj.count;
});
this.updateAll();
}
// Disable cart item so it doesn't count towards total amount
Cart.prototype.disableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
obj.disabled = true;
self.items -= obj.count;
}
});
this.updateAll();
}
// Enable cart item so it counts towards total amount
Cart.prototype.enableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
delete(obj.disabled);
self.items += obj.count;
}
});
this.updateAll();
}
Cart.prototype.decrementItem = function(id) {
var self = this;
this.items = 0; // Calculate total # of items from scratch just to make sure
// Decrement the existing item count
this.content.filter(function(obj, index, arr){
// Decrement the item count
if (obj.id === id)
{
obj.count--;
delete(obj.disabled);
// It's the last item with the same ID, remove it
if (obj.count === 0) {
if (obj.count <= 0) {
self.removeItem(id, index, arr);
}
}
self.items += obj.count;
});
this.items--;
this.saveLocalStorage();
this.itemsCount();
this.updateTotal();
this.updateAmount();
if (this.items === 0) {
this.emptyList();
}
this.updateAll();
}
Cart.prototype.removeItemAll = function(id) {
var self = this;
this.content.filter(function(obj, index, arr){
if (obj.id === id)
{
self.removeItem(id, index, arr);
for (var i = 0; i < obj.count; i++) {
self.items--;
}
}
});
this.saveLocalStorage();
this.itemsCount();
this.updateTotal();
this.updateAmount();
this.items = 0;
if (this.items === 0) {
this.emptyList();
// Remove by item
if (typeof id != 'undefined') {
this.content.filter(function(obj, index, arr){
if (obj.id === id) {
self.removeItem(id, index, arr);
for (var i = 0; i < obj.count; i++) {
self.items--;
}
}
self.items += obj.count;
});
} else { // Remove all
this.$list.find('tbody').empty();
this.content = [];
}
this.emptyCartToggle();
this.updateAll();
}
Cart.prototype.removeItem = function(id, index, arr) {
// Remove from the array
arr.splice(index, 1);
// Remove from the DOM
$('#js-cart-list').find('tr').eq(index+1).remove();
this.$list.find('tr').eq(index+1).remove();
}
Cart.prototype.setTip = function(tip) {
return this.tip = tip;
/*
* Update DOM
*/
// Update all data elements
Cart.prototype.updateAll = function() {
this.saveLocalStorage();
this.updateItemsCount();
this.updateDiscount();
this.updateSummaryProducts();
this.updateSummaryTotal();
this.updateTotal();
this.updateAmount();
}
Cart.prototype.itemsCount = function() {
$('#js-cart-items').text(this.items);
// Update number of cart items
Cart.prototype.updateItemsCount = function() {
this.$items.text(this.items);
}
Cart.prototype.getTotal = function(plain) {
this.totalAmount = 0;
// Update total products (including the custom amount and discount) in the cart
Cart.prototype.updateTotal = function() {
this.$total.text(this.formatCurrency(this.getTotal()));
}
// Always calculate the total amount based on the cart content
for (var key in this.content) {
if (this.content.hasOwnProperty(key) && typeof this.content[key] != 'undefined') {
var price = this.toCents(this.content[key].price.value);
this.totalAmount += (this.content[key].count * price);
}
// Update total amount in the summary
Cart.prototype.updateSummaryTotal = function() {
this.$summaryTotal.text(this.formatCurrency(this.getTotal(true)));
}
// Update total products amount in the summary
Cart.prototype.updateSummaryProducts = function() {
this.$summaryProducts.text(this.formatCurrency(this.fromCents(this.getTotalProducts())));
}
// Update discount amount in the summary
Cart.prototype.updateDiscount = function(amount) {
var discount = 0;
if (typeof amount != 'undefined') {
discount = amount;
} else {
discount = this.percentage(this.getTotalProducts(), this.getDiscount());
discount = this.fromCents(discount);
}
this.totalAmount += this.toCents(this.tip);
return this.fromCents(this.totalAmount);
this.$summaryDiscount.text((discount > 0 ? '-' : '') + this.formatCurrency(discount));
}
Cart.prototype.updateTotal = function() {
$('#js-cart-total').text(this.formatCurrency(this.getTotal(), srvModel.currencyCode, srvModel.currencySymbol));
// Update tip amount in the summary
Cart.prototype.updateTip = function(amount) {
var tip = typeof amount != 'undefined' ? amount : this.fromCents(this.getTip());
this.$summaryTip.text(this.formatCurrency(tip));
}
// Update hidden total amount value to be sent to the checkout page
Cart.prototype.updateAmount = function() {
$('#js-cart-amount').val(this.getTotal());
$('#js-cart-amount').val(this.getTotal(true));
}
Cart.prototype.resetDiscount = function() {
this.setDiscount(0);
this.updateDiscount(0);
$('.js-cart-discount').val('');
}
Cart.prototype.resetTip = function() {
this.setTip(0);
this.updateTip(0);
$('.js-cart-tip').val('');
}
Cart.prototype.resetCustomAmount = function() {
this.setCustomAmount(0);
$('.js-cart-custom-amount').val('');
}
// Escape html characters
Cart.prototype.escape = function(input) {
return ('' + input) /* Forces the conversion to string. */
.replace(/&/g, '&amp;') /* This MUST be the 1st replacement. */
@@ -132,8 +319,51 @@ Cart.prototype.escape = function(input) {
;
}
// Load the template
Cart.prototype.template = function($template, obj) {
var template = $template.text();
for (var key in obj) {
var re = new RegExp('{' + key + '}', 'mg');
template = template.replace(re, obj[key]);
}
return template;
}
// Build the cart skeleton
Cart.prototype.buildUI = function() {
var $table = $('#js-cart-extra').find('tbody'),
list = [];
tableTemplate = this.template($('#template-cart-extra'), {
'discount': this.escape(this.fromCents(this.getDiscount()) || ''),
'customAmount': this.escape(this.fromCents(this.getCustomAmount()) || '')
});
list.push($(tableTemplate));
tableTemplate = this.template($('#template-cart-total'), {
'total': this.escape(this.formatCurrency(this.getTotal()))
});
list.push($(tableTemplate));
// Add the list to DOM
$table.append(list);
// Change total when discount is changed
$('.js-cart-discount').inputAmount(this, 'discount');
// Remove discount
$('.js-cart-discount-remove').removeAmount(this, 'discount');
// Change total when discount is changed
$('.js-cart-custom-amount').inputAmount(this, 'customAmount');
// Remove discount
$('.js-cart-custom-amount-remove').removeAmount(this, 'customAmount');
}
// List cart items and bind their events
Cart.prototype.listItems = function() {
var $table = $('#js-cart-list').find('tbody'),
var $table = this.$list.find('tbody'),
self = this,
list = [],
tableTemplate = '';
@@ -142,74 +372,74 @@ Cart.prototype.listItems = function() {
// Prepare the list of items in the cart
for (var key in this.content) {
var item = this.content[key],
id = this.escape(item.id),
title = this.escape(item.title),
image = this.escape(item.image),
count = this.escape(item.count),
price = this.escape(item.price.formatted),
total = this.escape(this.formatCurrency(this.getTotal(), srvModel.currencyCode, srvModel.currencySymbol)),
step = this.escape(srvModel.step),
tip = this.escape(this.tip || ''),
customTipText = this.escape(srvModel.customTipText);
image = this.escape(item.image);
tableTemplate = '<tr data-id="' + id + '">' +
(image !== null ? '<td class="align-middle pr-0" width="60"><img src="' + image + '" width="100%"></td>' : '') +
'<td class="align-middle pr-0"><b>' + title + '</b></td>' +
'<td class="align-middle pr-0" align="right"><div class="input-group">' +
' <input class="js-cart-item-count form-control form-control-sm pull-left" type="number" min="0" step="1" name="count" placeholder="Qty" value="' + count + '" data-prev="' + count + '">' +
' <div class="input-group-append"><a class="js-cart-item-remove btn btn-danger btn-sm" href="#"><i class="fa fa-remove"></i></a></div>' +
'</div></td>' +
'<td class="align-middle" align="right">' + price + '</td>' +
'</tr>';
tableTemplate = this.template($('#template-cart-item'), {
'id': this.escape(item.id),
'image': image ? this.template($('#template-cart-item-image'), {
'image' : image
}) : '',
'title': this.escape(item.title),
'count': this.escape(item.count),
'price': this.escape(item.price.formatted)
});
list.push($(tableTemplate));
}
tableTemplate = '<tr><td colspan="4"><div class="row"><div class="col-sm-7 py-2">' + customTipText + '</div><div class="col-sm-5">' +
'<div class="input-group">' +
'<div class="input-group-prepend">' +
'<span class="input-group-text"><i class="fa fa-money"></i></span>' +
'</div>' +
'<input class="js-cart-tip form-control" type="number" min="0" step="' + step + '" value="' + tip + '" name="tip" placeholder="Amount">' +
'</div>' +
'</div></div></td></tr>';
list.push($(tableTemplate));
tableTemplate = '<tr class="bg-light h4"><td colspan="1">Total</td><td colspan="3" align="right"><span id="js-cart-total">' + total + '</span></td></tr>';
list.push($(tableTemplate));
// Add the list to DOM
$table.html(list);
list = [];
// Update the cart when number of items is changed
$('.js-cart-item-count').off().on('input', function(event){
var _this = this,
id = $(this).closest('tr').data('id'),
count = parseInt($(this).val()),
prevCount = parseInt($(this).data('prev')),
increased = count > prevCount;
qty = parseInt($(this).val()),
isQty = !isNaN(qty),
prevQty = parseInt($(this).data('prev')),
qtyDiff = Math.abs(qty - prevQty),
qtyIncreased = qty > prevQty;
// User hasn't inputed any number so stop here
if (isNaN(count)) {
return false;
if (isQty) {
$(this).data('prev', qty);
} else {
// User hasn't inputed any quantity
qty = null;
}
$(this).data('prev', count);
self.resetTip();
var item = self.content.filter(function(obj){
return obj.id === id
});
// Must be in the loop because user may change the count manually by more than 1
for (var i = 0; i < Math.abs(count - prevCount); i++) {
if (increased) {
// Quantity was increased
if (qtyIncreased) {
var item = self.content.filter(function(obj){
return obj.id === id;
});
// Quantity may have been increased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.addItem({
id: id,
title: item.title,
price: item.price,
image: typeof item.image != 'underfined' ? item.image : null
});
}
} else if (!qtyIncreased) { // Quantity decreased
// No quantity set (e.g. empty string)
if (!isQty) {
// Disable the item so it doesn't count towards total amount
self.disableItem(id);
} else {
self.decrementItem(id);
// Quantity vas decreased
if (qtyDiff > 0) {
// Quantity may have been decreased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.decrementItem(id);
}
} else {
// Quantity hasn't changed, enable the item so it counts towards the total amount
self.enableItem(id);
}
}
}
});
@@ -218,29 +448,71 @@ Cart.prototype.listItems = function() {
$('.js-cart-item-remove').off().on('click', function(event){
event.preventDefault();
var id = $(this).closest('tr').data('id');
self.removeItemAll(id);
self.resetTip();
self.removeItemAll($(this).closest('tr').data('id'));
});
// Change total when tip is changed
$('.js-cart-tip').off().on('input', function(event){
self.setTip($(this).val());
self.updateTotal();
self.updateAmount();
// Increment item
$('.js-cart-item-plus').off().on('click', function(event){
event.preventDefault();
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val);
$val.data('prev', val);
self.resetTip();
self.incrementItem($(this).closest('tr').data('id'));
});
// Decrement item
$('.js-cart-item-minus').off().on('click', function(event){
event.preventDefault();
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
id = $(this).closest('tr').data('id'),
val = parseInt($val.val() || $val.data('prev')) - 1;
self.resetTip();
if (val === 0) {
self.removeItemAll(id);
} else {
$val.val(val);
$val.data('prev', val);
self.decrementItem(id);
}
});
} else { // No item in the cart
self.emptyList();
}
}
Cart.prototype.emptyList = function() {
var $table = $('#js-cart-list').find('tbody');
Cart.prototype.bindEmptyCart = function() {
var self = this;
$table.html('<tr><td colspan="4">The cart is empty.</td></tr>');
this.emptyCartToggle();
this.$destroy.click(function(event){
event.preventDefault();
self.destroy();
self.emptyCartToggle();
});
}
Cart.prototype.formatCurrency = function(amount, currency, symbol) {
Cart.prototype.emptyCartToggle = function() {
if (this.content.length > 0 || this.getCustomAmount()) {
this.$destroy.show();
this.$confirm.removeAttr('disabled');
} else {
this.$destroy.hide();
this.$confirm.attr('disabled', 'disabled');
}
}
/*
* Currencies and numbers
*/
Cart.prototype.formatCurrency = function(amount) {
var amt = '',
thousandsSep = '',
decimalSep = ''
@@ -252,12 +524,14 @@ Cart.prototype.formatCurrency = function(amount, currency, symbol) {
if (srvModel.currencyInfo.symbolSpace) {
prefix = prefix + ' ';
}
}
else {
postfix = srvModel.currencyInfo.currencySymbol;
if (srvModel.currencyInfo.symbolSpace) {
postfix = ' ' + postfix;
}
}
thousandsSep = srvModel.currencyInfo.thousandSeparator;
decimalSep = srvModel.currencyInfo.decimalSeparator;
@@ -267,8 +541,9 @@ Cart.prototype.formatCurrency = function(amount, currency, symbol) {
var splittedAmount = amt.split('.');
amt = (splittedAmount[0] + '.').replace(/(\d)(?=(\d{3})+\.)/g, '$1' + thousandsSep);
amt = amt.substr(0, amt.length - 1);
if(splittedAmount.length == 2)
amt = amt + '.' + splittedAmount[1];
if(splittedAmount.length == 2) {
amt = amt + decimalSep + splittedAmount[1];
}
if (srvModel.currencyInfo.divisibility !== 0) {
amt[amt.length - srvModel.currencyInfo.divisibility - 1] = decimalSep;
}
@@ -277,6 +552,10 @@ Cart.prototype.formatCurrency = function(amount, currency, symbol) {
return amt;
}
Cart.prototype.toNumber = function(num) {
return (num * 1) || 0;
}
Cart.prototype.toCents = function(num) {
return num * Math.pow(10, srvModel.currencyInfo.divisibility);
}
@@ -285,27 +564,108 @@ Cart.prototype.fromCents = function(num) {
return num / Math.pow(10, srvModel.currencyInfo.divisibility);
}
Cart.prototype.getStorageKey = function () { return ('cart' + srvModel.appId + srvModel.currencyCode); }
Cart.prototype.percentage = function(amount, percentage) {
return this.fromCents((amount / 100) * percentage);
}
/*
* Storage
*/
Cart.prototype.getStorageKey = function (name) {
return (name + srvModel.appId + srvModel.currencyCode);
}
Cart.prototype.saveLocalStorage = function() {
localStorage.setItem(this.getStorageKey(), JSON.stringify(this.content));
localStorage.setItem(this.getStorageKey('cart'), JSON.stringify(this.content));
}
Cart.prototype.loadLocalStorage = function() {
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey())) || [];
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
// Get number of cart items
for (var key in this.content) {
if (this.content.hasOwnProperty(key) && typeof this.content[key] != 'undefined' && this.content[key] != null) {
this.items += this.content[key].count;
// Delete the disabled flag if any
delete(this.content[key].disabled);
}
}
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));
this.customAmount = localStorage.getItem(this.getStorageKey('cartCustomAmount'));
this.tip = localStorage.getItem(this.getStorageKey('cartTip'));
}
Cart.prototype.destroy = function() {
localStorage.removeItem(this.getStorageKey());
this.content = [];
this.items = 0;
this.totalAmount = 0;
this.tip = 0;
Cart.prototype.destroy = function(keepAmount) {
this.resetDiscount();
this.resetTip();
this.resetCustomAmount();
// When form is sent
if (keepAmount) {
this.content = [];
this.items = 0;
} else {
this.removeItemAll();
}
localStorage.removeItem(this.getStorageKey('cart'));
}
/*
* jQuery helpers
*/
$.fn.inputAmount = function(obj, type) {
$(this).off().on('input', function(event){
var val = obj.toNumber($(this).val());
switch (type) {
case 'customAmount':
obj.setCustomAmount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'discount':
obj.setDiscount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'tip':
obj.setTip(val);
obj.updateTip();
break;
}
obj.updateSummaryTotal();
obj.updateAmount();
obj.emptyCartToggle();
});
}
$.fn.removeAmount = function(obj, type) {
$(this).off().on('click', function(event){
event.preventDefault();
switch (type) {
case 'customAmount':
obj.resetCustomAmount();
obj.updateSummaryProducts();
break;
case 'discount':
obj.resetDiscount();
obj.updateSummaryProducts();
break;
}
obj.resetTip();
obj.updateTotal();
obj.updateSummaryTotal();
obj.emptyCartToggle();
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -31,15 +31,17 @@
showFeedback("no-ledger-info");
}
}
$("#DerivationScheme").change(function () {
$("#KeyPath").val("");
});
$(".ledger-info-recommended").on("click", function (elem) {
elem.preventDefault();
showFeedback("ledger-validate");
var account = elem.currentTarget.getAttribute("data-ledgeraccount");
var keypath = elem.currentTarget.getAttribute("data-ledgerkeypath");
var cryptoCode = GetSelectedCryptoCode();
bridge.sendCommand("getxpub", "cryptoCode=" + cryptoCode + "&account=" + account)
bridge.sendCommand("getxpub", "cryptoCode=" + cryptoCode + "&keypath=" + keypath)
.then(function (result) {
if (cryptoCode !== GetSelectedCryptoCode())
return;
@@ -48,33 +50,12 @@
$("#DerivationScheme").val(result.extPubKey);
$("#DerivationSchemeFormat").val("BTCPay");
$("#KeyPath").val(keypath);
})
.catch(function (reason) { Write('check', 'error', reason); });
return false;
});
var updateInfo = function () {
if (!ledgerDetected)
return false;
var cryptoCode = GetSelectedCryptoCode();
bridge.sendCommand("getxpub", "cryptoCode=" + cryptoCode)
.catch(function (reason) { Write('check', 'error', reason); })
.then(function (result) {
if (!result)
return;
if (cryptoCode !== GetSelectedCryptoCode())
return;
if (result.error) {
Write('check', 'error', result.error);
return;
}
else {
Write('check', 'success', 'This store is configured to use your ledger');
showFeedback("ledger-info");
}
});
};
bridge.isSupported()
.then(function (supported) {
if (!supported) {
@@ -95,7 +76,7 @@
} else {
Write('hw', 'success', 'Ledger detected');
ledgerDetected = true;
updateInfo();
showFeedback("ledger-info");
}
});
}

View File

@@ -41,52 +41,36 @@
var updateInfo = function () {
if (!ledgerDetected)
return false;
$(".crypto-info").css("display", "none");
bridge.sendCommand("getinfo", "cryptoCode=" + cryptoCode)
.catch(function (reason) { Write('check', 'error', reason); })
$(".crypto-info").css("display", "block");
var args = "";
args += "cryptoCode=" + cryptoCode;
args += "&destination=" + destination;
args += "&amount=" + amount;
args += "&feeRate=" + fee;
args += "&substractFees=" + substractFee;
WriteAlert("warning", 'Please validate the transaction on your ledger');
var confirmButton = $("#confirm-button");
confirmButton.prop("disabled", true);
confirmButton.addClass("disabled");
bridge.sendCommand('sendtoaddress', args, 60 * 10 /* timeout */)
.catch(function (reason) {
WriteAlert("danger", reason);
confirmButton.prop("disabled", false);
confirmButton.removeClass("disabled");
})
.then(function (result) {
if (!result)
return;
confirmButton.prop("disabled", false);
confirmButton.removeClass("disabled");
if (result.error) {
Write('check', 'error', result.error);
return;
}
else {
Write('check', 'success', 'This store is configured to use your ledger');
$(".crypto-info").css("display", "block");
var args = "";
args += "cryptoCode=" + cryptoCode;
args += "&destination=" + destination;
args += "&amount=" + amount;
args += "&feeRate=" + fee;
args += "&substractFees=" + substractFee;
WriteAlert("warning", 'Please validate the transaction on your ledger');
var confirmButton = $("#confirm-button");
confirmButton.prop("disabled", true);
confirmButton.addClass("disabled");
bridge.sendCommand('sendtoaddress', args, 60 * 10 /* timeout */)
.catch(function (reason) {
WriteAlert("danger", reason);
confirmButton.prop("disabled", false);
confirmButton.removeClass("disabled");
})
.then(function (result) {
if (!result)
return;
confirmButton.prop("disabled", false);
confirmButton.removeClass("disabled");
if (result.error) {
WriteAlert("danger", result.error);
} else {
WriteAlert("success", 'Transaction broadcasted (' + result.transactionId + ')');
window.location.replace(successCallback + "?txid=" + result.transactionId);
}
});
WriteAlert("danger", result.error);
} else {
WriteAlert("success", 'Transaction broadcasted (' + result.transactionId + ')');
window.location.replace(successCallback + "?txid=" + result.transactionId);
}
});
};

View File

@@ -0,0 +1,47 @@
{
"NOTICE_WARN": "THIS CODE HAS BEEN AUTOMATICALLY GENERATED FROM TRANSIFEX, IF YOU WISH TO HELP TRANSLATION COME ON THE SLACK http://slack.btcpayserver.org TO REQUEST PERMISSION TO https://www.transifex.com/btcpayserver/btcpayserver/",
"code": "tr",
"currentLanguage": "Türkçe",
"lang": "Dil",
"Awaiting Payment...": "Ödeme Bekleniyor...",
"Pay with": "Aracılığıyla Öde",
"Contact and Refund Email": "İletişim ve Geri Ödeme Email",
"Contact_Body": "Lütfen aşağıda bir email belirtin. Ödemede sorun olması halinde bu adresten sizinle iletişime geçeceğiz.",
"Your email": "Email Adresiniz",
"Continue": "Devam Et",
"Please enter a valid email address": "Lütfen geçerli bir email adresi giriniz",
"Order Amount": "Ödeme Tutarı",
"Network Cost": "Ağ Ücreti",
"Already Paid": "Zaten Ödendi",
"Due": "Bitiş Tarihi",
"Scan": "Tara",
"Copy": "Kopyala",
"Conversion": "Dönüştürme",
"Open in wallet": "Cüzdanda Aç",
"CompletePay_Body": "Ödemenizi tamamlamak için, lütfen {{btcDue}} {{cryptoCode}} bilgilerini aşağıdaki adrese gönderin.",
"Amount": "Tutar",
"Address": "Adres",
"Copied": "Kopyalandı",
"ConversionTab_BodyTop": "{{btcDue}} {{cryptoCode}} ödemenizi satıcının desteklemediği altcoinler ile de yapabilirsiniz.",
"ConversionTab_BodyDesc": "Bu servis 3. kişiler tarafından sağlanmaktadır. Ödemelerinizin iletildiği sağlayıcılar üzerinde bizim herhangi bir yetkimiz bulunmamaktadır. Faturanın ödendi olarak işaretlenmesi için ödemenin {{cryptoCode}} Blockchain ağından gelmesi gerekmektedir.",
"ConversionTab_CalculateAmount_Error": "Tekar dene",
"ConversionTab_LoadCurrencies_Error": "Tekrar dene",
"ConversionTab_Lightning": "Lightning Network ödemesi için dönüşüm sağlayıcı mevcut değil.",
"ConversionTab_CurrencyList_Select_Option": "Lütfen çevrim için bir para birimi seçin",
"Invoice expiring soon...": "Fatura son kullanım tarihi yakın...",
"Invoice expired": "Fatura son kullanım tarihi geçti",
"What happened?": "Ne oldu?",
"InvoiceExpired_Body_1": "Bu Fatura'nın son kullanım tarihi doldu. Bir Fatura sadece {{maxTimeMinutes}}  dakika geçerlidir.\nÖdemenizi tekrar göndermek istiyorsanız {{storeName}} mağazasına dönebilirsiniz.",
"InvoiceExpired_Body_2": "Ödemenizi göndermeyi denediyseniz, ağ tarafından henüz onaylanmadı. Ödemeniz bize ulaşmadı.",
"InvoiceExpired_Body_3": "Eğer daha sonra bize ulaşırsa, ya siparişinizi onaylarız ya da iade için sizinle iletişime geçeriz.",
"Invoice ID": "Fatura ID",
"Order ID": "Sipariş ID",
"Return to StoreName": "{{storeName}} dön",
"This invoice has been paid": "Bu Fatura ödendi",
"This invoice has been archived": "Bu Fatura arşivlendi",
"Archived_Body": "Bilgi ve yardım için lütfen mağaza ile iletişime geçin",
"BOLT 11 Invoice": "BOLT 11 Faturası",
"Node Info": "Düğüm Bilgisi",
"txCount": "{{count}} işlem",
"txCount_plural": "{{count}} işlem"
}

View File

@@ -0,0 +1,50 @@
{
"name": "BTCPayServer Point of Sale",
"short_name": "BTCPay POS",
"theme_color": "#1e7a44",
"background_color": "#ffffff",
"display": "standalone",
"icons": [
{
"src": "img/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "img/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "img/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "img/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "img/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "img/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "img/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "img/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"splash_pages": null
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff