Changelly Support (#267)

* Disable shapeshift and use changelly

* UI to manage changelly payment method

* wip on changelly api

* Add in Vue component for changelly and remove target currency from payment method

* add changelly merhcant id

* Small fixes to get Conversion to load

* wip fixing the component

* fix merge conflict

* fixes to UI

* remove debug, fix fee calc and move changelly to own partials

* Update ChangellyController.cs

* move original vue setup back to checkout

* Update core.js

* Extracting Changelly component to js file

* Proposal for loading spinner

* remove zone

* imrpove changelly ui

* add in changelly config checks

* try new method to calculate amount + remove to currency from list

* abstract changelly lofgic to provider and reduce dependency on js component

* Add UTs for Changelly

* refactor changelly backend

* fix failing UT

* add shitcoin tax

* pr changes

* pr changes
This commit is contained in:
Andrew Camilleri 2018-10-18 05:13:39 +02:00 committed by Nicolas Dorier
parent e18d0b5d51
commit a5fca7a1c4
23 changed files with 986 additions and 99 deletions

View file

@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Controllers;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Services.Stores;
using BTCPayServer.Tests.Logging;
using Changelly.ResponseModel;
using Microsoft.AspNetCore.Mvc;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class ChangellyTests
{
public ChangellyTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
public async void CanSetChangellyPaymentMethod()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var controller = tester.PayTester.GetController<StoresController>(user.UserId, user.StoreId);
var storeBlob = controller.StoreData.GetStoreBlob();
Assert.Null(storeBlob.ChangellySettings);
var updateModel = new UpdateChangellySettingsViewModel()
{
ApiSecret = "secret",
ApiKey = "key",
ApiUrl = "url",
ChangellyMerchantId = "aaa",
};
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
var store = await tester.PayTester.StoreRepository.FindStore(user.StoreId);
storeBlob = controller.StoreData.GetStoreBlob();
Assert.NotNull(storeBlob.ChangellySettings);
Assert.NotNull(storeBlob.ChangellySettings);
Assert.IsType<ChangellySettings>(storeBlob.ChangellySettings);
Assert.Equal(storeBlob.ChangellySettings.ApiKey, updateModel.ApiKey);
Assert.Equal(storeBlob.ChangellySettings.ApiSecret,
updateModel.ApiSecret);
Assert.Equal(storeBlob.ChangellySettings.ApiUrl, updateModel.ApiUrl);
Assert.Equal(storeBlob.ChangellySettings.ChangellyMerchantId,
updateModel.ChangellyMerchantId);
}
}
[Fact]
public async void CanToggleChangellyPaymentMethod()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var controller = tester.PayTester.GetController<StoresController>(user.UserId, user.StoreId);
var updateModel = new UpdateChangellySettingsViewModel()
{
ApiSecret = "secret",
ApiKey = "key",
ApiUrl = "url",
ChangellyMerchantId = "aaa",
};
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
var store = await tester.PayTester.StoreRepository.FindStore(user.StoreId);
Assert.True(store.GetStoreBlob().ChangellySettings.Enabled);
updateModel.Enabled = false;
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
store = await tester.PayTester.StoreRepository.FindStore(user.StoreId);
Assert.False(store.GetStoreBlob().ChangellySettings.Enabled);
}
}
[Fact]
public async void CannotUseChangellyApiWithoutChangellyPaymentMethodSet()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var changellyController =
tester.PayTester.GetController<ChangellyController>(user.UserId, user.StoreId);
//test non existing payment method
Assert.IsType<BitpayErrorModel>(Assert
.IsType<BadRequestObjectResult>(await changellyController.GetCurrencyList(user.StoreId))
.Value);
var updateModel = new UpdateChangellySettingsViewModel()
{
ApiSecret = "secret",
ApiKey = "key",
ApiUrl = "url",
ChangellyMerchantId = "aaa",
Enabled = false
};
var storesController = tester.PayTester.GetController<StoresController>(user.UserId, user.StoreId);
//set payment method but disabled
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
Assert.IsType<BitpayErrorModel>(Assert
.IsType<BadRequestObjectResult>(await changellyController.GetCurrencyList(user.StoreId))
.Value);
updateModel.Enabled = true;
//test with enabled method
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
Assert.IsNotType<BitpayErrorModel>(Assert
.IsType<BadRequestObjectResult>(await changellyController.GetCurrencyList(user.StoreId))
.Value);
}
}
[Fact]
public async void CanGetCurrencyListFromChangelly()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var updateModel = new UpdateChangellySettingsViewModel()
{
ApiSecret = "secret",
ApiKey = "key",
ApiUrl = "url",
ChangellyMerchantId = "aaa"
};
var storesController = tester.PayTester.GetController<StoresController>(user.UserId, user.StoreId);
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
var mock = new MockChangellyClientProvider(tester.PayTester.StoreRepository, tester.NetworkProvider);
var changellyController = new ChangellyController(mock);
mock.GetCurrenciesFullResult = (new List<CurrencyFull>()
{
new CurrencyFull()
{
Name = "a",
Enable = true,
PayInConfirmations = 10,
FullName = "aa",
ImageLink = ""
}
}, true, "");
var result = ((IList<CurrencyFull> currency, bool Success, string Error))Assert
.IsType<OkObjectResult>(await changellyController.GetCurrencyList(user.StoreId)).Value;
Assert.Equal(1, mock.GetCurrenciesFullCallCount);
Assert.Equal(mock.GetCurrenciesFullResult.currency.Count, result.currency.Count);
mock.GetCurrenciesFullResult = (new List<CurrencyFull>()
{
new CurrencyFull()
{
Name = "a",
Enable = true,
PayInConfirmations = 10,
FullName = "aa",
ImageLink = ""
}
}, false, "");
Assert
.IsType<BadRequestObjectResult>(await changellyController.GetCurrencyList(user.StoreId));
Assert.Equal(2, mock.GetCurrenciesFullCallCount);
}
}
[Fact]
public async void CanCalculateToAmountForChangelly()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
var updateModel = new UpdateChangellySettingsViewModel()
{
ApiSecret = "secret",
ApiKey = "key",
ApiUrl = "url",
ChangellyMerchantId = "aaa"
};
var storesController = tester.PayTester.GetController<StoresController>(user.UserId, user.StoreId);
Assert.Equal("UpdateStore", Assert.IsType<RedirectToActionResult>(
await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName);
var mock = new MockChangellyClientProvider(tester.PayTester.StoreRepository, tester.NetworkProvider);
var changellyController = new ChangellyController(mock);
mock.GetExchangeAmountResult = (from, to, amount) =>
{
Assert.Equal("A", from);
Assert.Equal("B", to);
switch (mock.GetExchangeAmountCallCount)
{
case 1:
return (0.5, true, null);
break;
default:
return (1.01, true, null);
break;
}
};
Assert.IsType<double>(Assert
.IsType<OkObjectResult>(changellyController.CalculateAmount(user.StoreId, "A", "B", 1.0)).Value);
Assert.True(mock.GetExchangeAmountCallCount > 1);
}
}
}
public class MockChangellyClientProvider : ChangellyClientProvider
{
public MockChangellyClientProvider(
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider) : base(storeRepository, btcPayNetworkProvider)
{
}
public (IList<CurrencyFull> currency, bool Success, string Error) GetCurrenciesFullResult { get; set; }
public delegate TResult ParamsFunc<T1, T2, T3, TResult>(T1 arg1, T2 arg2, T3 arg3);
public ParamsFunc<string, string, double, (double amount, bool Success, string Error)> GetExchangeAmountResult
{
get;
set;
}
public int GetCurrenciesFullCallCount { get; set; } = 0;
public int GetExchangeAmountCallCount { get; set; } = 0;
public override (IList<CurrencyFull> currency, bool Success, string Error) GetCurrenciesFull(
Changelly.Changelly client)
{
GetCurrenciesFullCallCount++;
return GetCurrenciesFullResult;
}
public override (double amount, bool Success, string Error) GetExchangeAmount(Changelly.Changelly client,
string fromCurrency, string toCurrency,
double amount)
{
GetExchangeAmountCallCount++;
return GetExchangeAmountResult.Invoke(fromCurrency, toCurrency, amount);
}
}
}

View file

@ -35,6 +35,7 @@
<ItemGroup>
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.1.0.1" />
<PackageReference Include="BuildBundlerMinifier" Version="2.7.385" />
<PackageReference Include="Changelly" Version="1.1.0" />
<PackageReference Include="DigitalRuby.ExchangeSharp" Version="0.5.3" />
<PackageReference Include="Hangfire" Version="1.6.19" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.5.2" />

View file

@ -0,0 +1,103 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Models;
using BTCPayServer.Payments.Changelly;
using Changelly.ResponseModel;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
[Route("[controller]/{storeId}")]
public class ChangellyController : Controller
{
private readonly ChangellyClientProvider _changellyClientProvider;
public ChangellyController(ChangellyClientProvider changellyClientProvider)
{
_changellyClientProvider = changellyClientProvider;
}
[HttpGet]
[Route("currencies")]
public async Task<IActionResult> GetCurrencyList(string storeId)
{
if (!TryGetChangellyClient(storeId, out var actionResult, out var client))
{
return actionResult;
}
var result = _changellyClientProvider.GetCurrenciesFull(client);
if (result.Success)
{
return Ok(result);
}
return BadRequest(result);
}
[HttpGet]
[Route("calculate")]
public IActionResult CalculateAmount(string storeId, string fromCurrency, string toCurrency,
double toCurrencyAmount)
{
if (!TryGetChangellyClient(storeId, out var actionResult, out var client))
{
return actionResult;
}
double? currentAmount = null;
var callCounter = 0;
var response1 = _changellyClientProvider.GetExchangeAmount(client,fromCurrency, toCurrency, 1);
if (!response1.Success) return BadRequest(response1);
currentAmount = response1.amount;
while (true)
{
if (callCounter > 10)
{
BadRequest();
}
//Client needs to be reset between same calls for some reason
if (!TryGetChangellyClient(storeId, out actionResult, out client))
{
return actionResult;
}
var response2 = _changellyClientProvider.GetExchangeAmount(client,fromCurrency, toCurrency, currentAmount.Value);
callCounter++;
if (!response2.Success) return BadRequest(response2);
if (response2.amount < toCurrencyAmount)
{
var newCurrentAmount = ((toCurrencyAmount / response2.amount) * 1) * currentAmount.Value;
currentAmount = newCurrentAmount;
}
else
{
return Ok(currentAmount.Value);
}
}
}
private bool TryGetChangellyClient(string storeId, out IActionResult actionResult,
out Changelly.Changelly changelly)
{
changelly = null;
actionResult = null;
storeId = storeId ?? HttpContext.GetStoreData()?.Id;
if (!_changellyClientProvider.TryGetChangellyClient(storeId, out var error, out changelly))
{
actionResult = BadRequest(new BitpayErrorModel()
{
Error = error
});
return false;
}
return true;
}
}
}

View file

@ -10,6 +10,7 @@ using BTCPayServer.Events;
using BTCPayServer.Filters;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services.Invoices;
@ -213,7 +214,6 @@ namespace BTCPayServer.Controllers
paymentMethodIdStr = store.GetDefaultCrypto(_NetworkProvider);
isDefaultCrypto = true;
}
var paymentMethodId = PaymentMethodId.Parse(paymentMethodIdStr);
var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode);
if (network == null && isDefaultCrypto)
@ -245,6 +245,18 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob();
var currency = invoice.ProductInformation.Currency;
var accounting = paymentMethod.Calculate();
ChangellySettings changelly = (storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled &&
storeBlob.ChangellySettings.IsConfigured())
? storeBlob.ChangellySettings
: null;
var changellyAmountDue = changelly != null
? (accounting.Due.ToDecimal(MoneyUnit.BTC) *
(1m + (changelly.AmountMarkupPercentage / 100m)))
: (decimal?)null;
var model = new PaymentModel()
{
CryptoCode = network.CryptoCode,
@ -284,7 +296,10 @@ namespace BTCPayServer.Controllers
Status = invoice.Status,
NetworkFee = paymentMethodDetails.GetTxFee(),
IsMultiCurrency = invoice.GetPayments().Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1,
AllowCoinConversion = storeBlob.AllowCoinConversion,
ChangellyEnabled = changelly != null,
ChangellyMerchantId = changelly?.ChangellyMerchantId,
ChangellyAmountDue = changellyAmountDue,
StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentMethods(_NetworkProvider)
.Where(i => i.Network != null)
.Select(kv => new PaymentModel.AvailableCrypto()

View file

@ -0,0 +1,98 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments.Changelly;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class StoresController
{
[HttpGet]
[Route("{storeId}/changelly")]
public IActionResult UpdateChangellySettings(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
UpdateChangellySettingsViewModel vm = new UpdateChangellySettingsViewModel();
SetExistingValues(store, vm);
return View(vm);
}
private void SetExistingValues(StoreData store, UpdateChangellySettingsViewModel vm)
{
var existing = store.GetStoreBlob().ChangellySettings;
if (existing == null) return;
vm.ApiKey = existing.ApiKey;
vm.ApiSecret = existing.ApiSecret;
vm.ApiUrl = existing.ApiUrl;
vm.ChangellyMerchantId = existing.ChangellyMerchantId;
vm.Enabled = existing.Enabled;
vm.AmountMarkupPercentage = existing.AmountMarkupPercentage;
}
[HttpPost]
[Route("{storeId}/changelly")]
public async Task<IActionResult> UpdateChangellySettings(string storeId, UpdateChangellySettingsViewModel vm,
string command)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
if (vm.Enabled)
{
if (!ModelState.IsValid)
{
return View(vm);
}
}
var changellySettings = new ChangellySettings()
{
ApiKey = vm.ApiKey,
ApiSecret = vm.ApiSecret,
ApiUrl = vm.ApiUrl,
ChangellyMerchantId = vm.ChangellyMerchantId,
Enabled = vm.Enabled,
AmountMarkupPercentage = vm.AmountMarkupPercentage
};
switch (command)
{
case "save":
var storeBlob = store.GetStoreBlob();
storeBlob.ChangellySettings = changellySettings;
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
StatusMessage = "Changelly settings modified";
return RedirectToAction(nameof(UpdateStore), new {
storeId});
case "test":
try
{
var client = new Changelly.Changelly(changellySettings.ApiKey, changellySettings.ApiSecret,
changellySettings.ApiUrl);
var result = client.GetCurrenciesFull();
vm.StatusMessage = !result.Success
? $"Error: {result.Error}"
: "Test Successful";
return View(vm);
}
catch (Exception ex)
{
vm.StatusMessage = $"Error: {ex.Message}";
return View(vm);
}
break;
default:
return View(vm);
}
}
}
}

View file

@ -9,6 +9,7 @@ using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Services;
@ -318,7 +319,6 @@ namespace BTCPayServer.Controllers
vm.SetLanguages(_LangService, storeBlob.DefaultLang);
vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? "";
vm.OnChainMinValue = storeBlob.OnChainMinValue?.ToString() ?? "";
vm.AllowCoinConversion = storeBlob.AllowCoinConversion;
vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail;
vm.CustomCSS = storeBlob.CustomCSS?.AbsoluteUri;
vm.CustomLogo = storeBlob.CustomLogo?.AbsoluteUri;
@ -362,7 +362,6 @@ namespace BTCPayServer.Controllers
return View(model);
}
blob.DefaultLang = model.DefaultLang;
blob.AllowCoinConversion = model.AllowCoinConversion;
blob.RequiresRefundEmail = model.RequiresRefundEmail;
blob.LightningMaxValue = lightningMaxValue;
blob.OnChainMinValue = onchainMinValue;
@ -447,6 +446,15 @@ namespace BTCPayServer.Controllers
Enabled = !excludeFilters.Match(paymentId)
});
}
var changellyEnabled = storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled;
vm.ThirdPartyPaymentMethods.Add(new StoreViewModel.ThirdPartyPaymentMethod()
{
Enabled = changellyEnabled,
Action = nameof(UpdateChangellySettings),
Provider = "Changelly"
});
}
[HttpPost]

View file

@ -17,6 +17,7 @@ using BTCPayServer.JsonConverters;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services;
using System.Security.Claims;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Security;
using BTCPayServer.Rating;
@ -261,11 +262,6 @@ namespace BTCPayServer.Data
{
get; set;
}
public bool AllowCoinConversion
{
get; set;
}
public bool RequiresRefundEmail { get; set; }
public string DefaultLang { get; set; }
@ -307,6 +303,8 @@ namespace BTCPayServer.Data
public string RateScript { get; set; }
public bool AnyoneCanInvoice { get; set; }
public ChangellySettings ChangellySettings { get; set; }
string _LightningDescriptionTemplate;

View file

@ -38,6 +38,7 @@ using BTCPayServer.Logging;
using BTCPayServer.HostedServices;
using Meziantou.AspNetCore.BundleTagHelpers;
using System.Security.Claims;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBXplorer.DerivationStrategy;
@ -125,6 +126,8 @@ namespace BTCPayServer.Hosting
services.AddSingleton<Payments.IPaymentMethodHandler<Payments.Lightning.LightningSupportedPaymentMethod>, Payments.Lightning.LightningLikePaymentHandler>();
services.AddSingleton<IHostedService, Payments.Lightning.LightningListener>();
services.AddSingleton<ChangellyClientProvider>();
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, InvoiceNotificationManager>();

View file

@ -55,7 +55,10 @@ namespace BTCPayServer.Models.InvoicingModels
public string PaymentMethodName { get; set; }
public string CryptoImage { get; set; }
public bool AllowCoinConversion { get; set; }
public bool ChangellyEnabled { get; set; }
public string StoreId { get; set; }
public string PeerInfo { get; set; }
public string ChangellyMerchantId { get; set; }
public decimal? ChangellyAmountDue { get; set; }
}
}

View file

@ -23,11 +23,6 @@ namespace BTCPayServer.Models.StoreViewModels
public string DefaultCryptoCurrency { get; set; }
[Display(Name = "Default language on checkout")]
public string DefaultLang { get; set; }
[Display(Name = "Allow conversion through third party (Shapeshift, Changelly...)")]
public bool AllowCoinConversion
{
get; set;
}
[Display(Name = "Do not propose lightning payment if value of the invoice is above...")]
[MaxLength(20)]
public string LightningMaxValue { get; set; }

View file

@ -21,7 +21,13 @@ namespace BTCPayServer.Models.StoreViewModels
public WalletId WalletId { get; set; }
public bool Enabled { get; set; }
}
public class ThirdPartyPaymentMethod
{
public string Provider { get; set; }
public bool Enabled { get; set; }
public string Action { get; set; }
}
public StoreViewModel()
{
@ -52,6 +58,9 @@ namespace BTCPayServer.Models.StoreViewModels
public List<StoreViewModel.DerivationScheme> DerivationSchemes { get; set; } = new List<StoreViewModel.DerivationScheme>();
public List<ThirdPartyPaymentMethod> ThirdPartyPaymentMethods { get; set; } =
new List<ThirdPartyPaymentMethod>();
[Display(Name = "Invoice expires if the full amount has not been paid after ... minutes")]
[Range(1, 60 * 24 * 24)]
public int InvoiceExpiration

View file

@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Runtime.InteropServices;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.StoreViewModels
{
public class UpdateChangellySettingsViewModel
{
[Required]
public string ApiKey { get; set; }
[Required]
public string ApiSecret { get; set; }
[Required]
public string ApiUrl { get; set; } = "https://api.changelly.com";
[Display(Name="Optional, Changelly Merchant Id")]
public string ChangellyMerchantId { get; set; } = "804298eb5753";
public bool Enabled { get; set; } = true;
public string StatusMessage { get; set; }
[Required]
[Range(0, 100)]
[Display(Name = "Percentage to multiply amount requested at Changelly to avoid underpaid situations due to Changelly not guaranteeing rates. ")]
public decimal AmountMarkupPercentage { get; set; } = new decimal(2);
}
}

View file

@ -0,0 +1,68 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Services.Stores;
using Changelly.ResponseModel;
namespace BTCPayServer.Payments.Changelly
{
public class ChangellyClientProvider
{
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
public ChangellyClientProvider(StoreRepository storeRepository, BTCPayNetworkProvider btcPayNetworkProvider)
{
_storeRepository = storeRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
}
public virtual bool TryGetChangellyClient(string storeId, out string error,
out global::Changelly.Changelly changelly)
{
changelly = null;
var store = _storeRepository.FindStore(storeId).Result;
if (store == null)
{
error = "Store not found";
return false;
}
var blob = store.GetStoreBlob();
var changellySettings = blob.ChangellySettings;
if (changellySettings == null || !changellySettings.IsConfigured())
{
error = "Changelly not configured for this store";
return false;
}
if (!changellySettings.Enabled)
{
error = "Changelly not enabled for this store";
return false;
}
changelly = new global::Changelly.Changelly(changellySettings.ApiKey, changellySettings.ApiSecret,
changellySettings.ApiUrl);
error = null;
return true;
}
public virtual (IList<CurrencyFull> currency, bool Success, string Error) GetCurrenciesFull(global::Changelly.Changelly client)
{
return client.GetCurrenciesFull();
}
public virtual (double amount, bool Success, string Error) GetExchangeAmount(global::Changelly.Changelly client, string fromCurrency, string toCurrency,
double amount)
{
return client.GetExchangeAmount(fromCurrency, toCurrency, amount);
}
}
}

View file

@ -0,0 +1,20 @@
namespace BTCPayServer.Payments.Changelly
{
public class ChangellySettings
{
public string ApiKey { get; set; }
public string ApiSecret { get; set; }
public string ApiUrl { get; set; }
public bool Enabled { get; set; }
public string ChangellyMerchantId { get; set; }
public decimal AmountMarkupPercentage { get; set; }
public bool IsConfigured()
{
return
!string.IsNullOrEmpty(ApiKey) ||
!string.IsNullOrEmpty(ApiSecret) ||
!string.IsNullOrEmpty(ApiUrl);
}
}
}

View file

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Payments.Changelly;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

View file

@ -148,7 +148,7 @@
<div class="payment-tabs__tab" id="copy-tab">
<span>{{$t("Copy")}}</span>
</div>
@if (Model.AllowCoinConversion)
@if (Model.ChangellyEnabled)
{
<div class="payment-tabs__tab" id="altcoins-tab">
<span>{{$t("Conversion")}}</span>
@ -253,7 +253,7 @@
</div>
</nav>
</div>
@if (Model.AllowCoinConversion)
@if (Model.ChangellyEnabled)
{
<div id="altcoins" class="bp-view payment manual-flow">
<nav v-if="srvModel.isLightning">
@ -271,17 +271,32 @@
{{$t("ConversionTab_BodyDesc", srvModel)}}
</span>
</div>
<center>
<script>function shapeshift_click(a, e) { e.preventDefault(); var link = a.href; var shapeshiftWindow = window.open(link, '1418115287605', 'width=700,height=500,toolbar=0,menubar=0,location=0,status=1,scrollbars=1,resizable=0,left=0,top=0'); shapeshiftWindow.focus(); return false; }</script>
<a onclick="shapeshift_click(this, event);" v-bind:href="srvModel.shapeshiftUrl">
<img src="https://shapeshift.io/images/shifty/xs_light_altcoins.png" class="ss-button">
</a>
@*Changelly doesn't have TO_AMOUNT support so we can't include it
<script type="text/javascript">function open_widget(a, e) { e.preventDefault(); var link = a.href; var changellyWindow = window.open(link, 'Changelly', 'width=600,height=470,toolbar=0,menubar=0,location=0,status=1,scrollbars=1,resizable=0,left=0,top=0'); changellyWindow.focus(); return false; }</script>
<a onclick="open_widget(this, event);" href="https://changelly.com/widget/v1?auth=email&from=DASH&to=BTC&address=&amount=1&merchant_id=&ref_id=">
<img src="https://changelly.com/pay_button_pay_with.png" alt="Changelly" />
</a>*@
<center>
<changelly inline-template
:merchant-id="srvModel.changellyMerchantId"
:store-id="srvModel.storeId"
:to-currency="srvModel.paymentMethodId"
:to-currency-due="srvModel.changellyAmountDue"
:to-currency-address="srvModel.btcAddress">
<div class="changelly-component">
<div class="changelly-component-dropdown-holder" v-show="prettyDropdownInstance">
<select
v-model="selectedFromCurrency"
:disabled="isLoading"
v-on:change="onCurrencyChange($event)"
ref="changellyCurrenciesDropdown">
<option value="">Select a currency to convert from</option>
<option v-for="currency of currencies" :value="currency.name">{{currency.fullName}}</option>
</select>
</div>
<a v-on:click="openDialog($event)" :href="url" class="changelly-component-button">
<img src="https://changelly.com/pay_button.png" alt="Changelly" v-show="url"/>
</a>
<div v-show="isLoading" class="general__spinner">
<partial name="Checkout-Spinner"/>
</div>
</div>
</changelly>
</center>
</nav>
</div>

View file

@ -53,49 +53,53 @@
</center>
<![endif]-->
<invoice>
<div class="no-bounce" id="checkoutCtrl" v-cloak>
<div class="modal page">
<div class="modal-dialog open opened enter-purchaser-email" role="document">
<div class="modal-content long">
<div class="content">
<div class="invoice">
<partial name="Checkout-Body" />
</div>
<invoice>
<div class="no-bounce" id="checkoutCtrl" v-cloak>
<div class="modal page">
<div class="modal-dialog open opened enter-purchaser-email" role="document">
<div class="modal-content long">
<div class="content">
<div class="invoice">
<partial name="Checkout-Body" />
</div>
</div>
</div>
<div style="margin-top: 10px; text-align: center;">
@* Not working because of nsSeparator: false, keySeparator: false,
<div style="margin-top: 10px; text-align: center;">
@* Not working because of nsSeparator: false, keySeparator: false,
{{$t("nested.lang")}} >>
*@
<select class="cmblang reverse invisible" onchange="changeLanguage($(this).val())">
@foreach (var lang in langService.GetLanguages())
{
<option value="@lang.Code">@lang.DisplayName</option>
<select class="cmblang reverse invisible" onchange="changeLanguage($(this).val())">
@foreach (var lang in langService.GetLanguages())
{
<option value="@lang.Code">@lang.DisplayName</option>
}
</select>
<script>
$(function() {
var storeDefaultLang = '@Model.DefaultLang';
if (urlParams.lang) {
$(".cmblang").val(urlParams.lang);
} else if (storeDefaultLang) {
$(".cmblang").val(storeDefaultLang);
}
</select>
<script>
$(function () {
var storeDefaultLang = '@Model.DefaultLang';
if (urlParams.lang) {
$(".cmblang").val(urlParams.lang);
} else if (storeDefaultLang) {
$(".cmblang").val(storeDefaultLang);
}
$('select').prettyDropdown({
classic: false,
height: 32,
reverse: true,
hoverIntent: 5000
});
// REVIEW: don't use initDropdown method but rather directly initialize select whenever you are using it
initDropdown(".cmblang");
});
function initDropdown(selector) {
return $(selector).prettyDropdown({
classic: false,
height: 32,
reverse: true,
hoverIntent: 5000
});
</script>
</div>
<div style="margin-top: 10px; text-align: center;" class="form-text small text-muted">
<span>Powered by <a target="_blank" href="https://github.com/btcpayserver/btcpayserver">BTCPay Server</a></span>
</div>
}
</script>
</div>
<div style="margin-top: 10px; text-align: center;" class="form-text small text-muted">
<span>Powered by <a target="_blank" href="https://github.com/btcpayserver/btcpayserver">BTCPay Server</a></span>
</div>
</div>
</div>
@ -127,39 +131,40 @@
},
});
function changeLanguage(lang) {
i18next.changeLanguage(lang);
}
function changeLanguage(lang) {
i18next.changeLanguage(lang);
}
if (urlParams.lang) {
changeLanguage(urlParams.lang);
}
else if (storeDefaultLang) {
changeLanguage(storeDefaultLang);
}
if (urlParams.lang) {
changeLanguage(urlParams.lang);
} else if (storeDefaultLang) {
changeLanguage(storeDefaultLang);
}
const i18n = new VueI18next(i18next);
const i18n = new VueI18next(i18next);
// TODO: Move all logic from core.js to Vue controller
Vue.config.ignoredElements = [
'line-items',
'low-fee-timeline',
// Ignoring custom HTML5 elements, eg: bp-spinner
/^bp-/
];
var checkoutCtrl = new Vue({
i18n: i18n,
el: '#checkoutCtrl',
components: {
qrcode: VueQr
},
data: {
// TODO: Move all logic from core.js to Vue controller
Vue.config.ignoredElements = [
'line-items',
'low-fee-timeline',
// Ignoring custom HTML5 elements, eg: bp-spinner
/^bp-/
];
var checkoutCtrl = new Vue({
i18n: i18n,
el: '#checkoutCtrl',
components: {
qrcode: VueQr,
changelly: ChangellyComponent
},
data: {
srvModel: srvModel,
lndModel: null,
scanDisplayQr: "",
expiringSoon: false
}
});
</script>
}
});
</script>
</body>
</html>

View file

@ -38,10 +38,6 @@
<label asp-for="DefaultLang"></label>
<select asp-for="DefaultLang" asp-items="Model.Languages" class="form-control"></select>
</div>
<div class="form-group">
<label asp-for="AllowCoinConversion"></label>
<input asp-for="AllowCoinConversion" type="checkbox" class="form-check" />
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail"></label>
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check" />

View file

@ -0,0 +1,51 @@
@using Microsoft.AspNetCore.Mvc.Rendering
@model UpdateChangellySettingsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.Index, "Update Store Changelly Settings");
}
<h4>@ViewData["Title"]</h4>
<partial name="_StatusMessage" for="StatusMessage"/>
<div class="row">
<div class="col-md-10">
<form method="post">
<div class="form-group">
<label asp-for="ApiUrl"></label>
<input asp-for="ApiUrl" class="form-control"/>
<span asp-validation-for="ApiUrl" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ApiKey"></label>
<input asp-for="ApiKey" class="form-control"/>
<span asp-validation-for="ApiKey" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ApiSecret"></label>
<input asp-for="ApiSecret" class="form-control"/>
<span asp-validation-for="ApiSecret" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ChangellyMerchantId"></label>
<input asp-for="ChangellyMerchantId" class="form-control"/>
<span asp-validation-for="ChangellyMerchantId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="AmountMarkupPercentage"></label>
<input asp-for="AmountMarkupPercentage" class="form-control"/>
<span asp-validation-for="AmountMarkupPercentage" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Enabled"></label>
<input asp-for="Enabled" type="checkbox" class="form-check"/>
</div>
<button name="command" type="submit" value="save" class="btn btn-primary">Submit</button>
<button name="command" type="submit" value="test" class="btn btn-primary">Test Settings</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -168,6 +168,43 @@
Available placeholders are: {StoreName}, {ItemDescription} and {OrderId}
</p>
</div>
<div class="form-group">
<div class="form-group">
<h5>Third party Payment methods</h5>
</div>
<div class="form-group">
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th>Provider</th>
<th style="text-align:center;">Enabled</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
@foreach(var scheme in Model.ThirdPartyPaymentMethods)
{
<tr>
<td>@scheme.Provider</td>
<td style="text-align:center;">
@if(scheme.Enabled)
{
<span class="fa fa-check"></span>
}
else
{
<span class="fa fa-times"></span>
}
</td>
<td style="text-align:right"><a asp-action="@scheme.Action" >Modify</a></td>
</tr>
}
</tbody>
</table>
</div>
</div>
@if(Model.CanDelete)
{
<div class="form-group">

View file

@ -9390,7 +9390,7 @@ strong {
background-color: #fff;
color: #000;
font-size: 0px;
width:100%;
width: 100%;
}
.btnGroupLnd button:first-child {
@ -10995,6 +10995,15 @@ bp-spinner {
opacity: .85;
}
.general__spinner > bp-spinner > svg {
margin: auto 0px 0px auto;
height: 32px;
width: 32px;
fill: gray;
animation: spin 0.55s linear infinite;
opacity: .85;
}
bp-refund-address.ng-valid .bitcoin-logo {
opacity: 1;
margin-left: 0;
@ -11459,3 +11468,16 @@ low-fee-timeline {
opacity: 1;
transition: opacity 1s ease;
}
.changelly-component {
position: relative;
}
.changelly-component-dropdown-holder {
height: 32px;
margin-bottom: 10px;
}
.changelly-component .general__spinner bp-spinner {
width: 50px;
height: 50px;
}

View file

@ -0,0 +1,115 @@
var ChangellyComponent =
{
props: ["storeId", "toCurrency", "toCurrencyDue", "toCurrencyAddress", "merchantId"],
data: function () {
return {
currencies: [],
isLoading: true,
calculatedAmount: 0,
selectedFromCurrency: "",
prettyDropdownInstance: null
};
},
computed: {
url: function () {
if (this.calculatedAmount && this.selectedFromCurrency && !this.isLoading) {
return "https://changelly.com/widget/v1?auth=email" +
"&from=" +
this.selectedFromCurrency +
"&to=" +
this.toCurrency +
"&address=" +
this.toCurrencyAddress +
"&amount=" +
this.calculatedAmount +
(this.merchantId ? "&merchant_id=" + this.merchantId + "&ref_id=" + this.merchantId : "");
}
return null;
}
},
watch: {
selectedFromCurrency: function (val) {
if (val) {
this.calculateAmount();
} else {
this.calculateAmount = 0;
}
}
},
mounted: function () {
this.prettyDropdownInstance = initDropdown(this.$refs.changellyCurrenciesDropdown);
this.loadCurrencies();
},
methods: {
getUrl: function () {
return window.location.origin + "/changelly/" + this.storeId;
},
loadCurrencies: function () {
this.isLoading = true;
$.ajax(
{
context: this,
url: this.getUrl() + "/currencies",
dataType: "json",
success: function (result) {
if (result.item2) {
for (i = 0; i < result.item1.length; i++) {
if (result.item1[i].enabled &&
result.item1[i].name.toLowerCase() !== this.toCurrency.toLowerCase()) {
this.currencies.push(result.item1[i]);
}
}
var self = this;
Vue.nextTick(function () {
self.prettyDropdownInstance
.refresh()
.on("change",
function (event) {
self.onCurrencyChange(self.$refs.changellyCurrenciesDropdown.value);
});
});
}
},
complete: function () {
this.isLoading = false;
}
});
},
calculateAmount: function () {
this.isLoading = true;
$.ajax(
{
url: this.getUrl() + "/calculate",
dataType: "json",
data: {
fromCurrency: this.selectedFromCurrency,
toCurrency: this.toCurrency,
toCurrencyAmount: this.toCurrencyDue
},
context: this,
success: function (result) {
this.calculatedAmount = result;
},
complete: function () {
this.isLoading = false;
}
});
},
onCurrencyChange: function (value) {
this.selectedFromCurrency = value;
},
openDialog: function (e) {
if (e && e.preventDefault) {
e.preventDefault();
}
var changellyWindow = window.open(
this.url,
'Changelly',
'width=600,height=470,toolbar=0,menubar=0,location=0,status=1,scrollbars=1,resizable=0,left=0,top=0');
changellyWindow.focus();
}
}
};

View file

@ -32,9 +32,6 @@ function changeCurrency(currency) {
}
function onDataCallback(jsonData) {
// extender properties used
jsonData.shapeshiftUrl = "https://shapeshift.io/shifty.html?destination=" + jsonData.btcAddress + "&output=" + jsonData.paymentMethodId + "&amount=" + jsonData.btcDue;
//
var newStatus = jsonData.status;