diff --git a/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs b/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs new file mode 100644 index 000000000..2b8c83b0b --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.StoreRatesConfiguration.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task GetStoreRateConfiguration(string storeId, + CancellationToken token = default) + { + using var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration", method: HttpMethod.Get), + token); + return await HandleResponse(response); + } + + public virtual async Task> GetRateSources( + CancellationToken token = default) + { + using var response = await _httpClient.SendAsync( + CreateHttpRequest($"misc/rate-sources", method: HttpMethod.Get), + token); + return await HandleResponse>(response); + } + + public virtual async Task UpdateStoreRateConfiguration(string storeId, + StoreRateConfiguration request, + CancellationToken token = default) + { + using var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration", bodyPayload: request, + method: HttpMethod.Put), + token); + return await HandleResponse(response); + } + + public virtual async Task> PreviewUpdateStoreRateConfiguration(string storeId, + StoreRateConfiguration request, + string[] currencyPair, + CancellationToken token = default) + { + using var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration/preview", bodyPayload: request, + queryPayload: new Dictionary() {{"currencyPair", currencyPair}}, + method: HttpMethod.Post), + token); + return await HandleResponse>(response); + } + } +} diff --git a/BTCPayServer.Client/Models/RateSource.cs b/BTCPayServer.Client/Models/RateSource.cs new file mode 100644 index 000000000..c44cc23ad --- /dev/null +++ b/BTCPayServer.Client/Models/RateSource.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Client.Models; + +public class RateSource +{ + public string Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/StoreRateConfiguration.cs b/BTCPayServer.Client/Models/StoreRateConfiguration.cs new file mode 100644 index 000000000..3dd548278 --- /dev/null +++ b/BTCPayServer.Client/Models/StoreRateConfiguration.cs @@ -0,0 +1,10 @@ +namespace BTCPayServer.Client.Models +{ + public class StoreRateConfiguration + { + public decimal Spread { get; set; } + public bool IsCustomScript { get; set; } + public string EffectiveScript { get; set; } + public string PreferredSource { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/StoreRatePreviewResult.cs b/BTCPayServer.Client/Models/StoreRatePreviewResult.cs new file mode 100644 index 000000000..2c5ad5b0e --- /dev/null +++ b/BTCPayServer.Client/Models/StoreRatePreviewResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace BTCPayServer.Client.Models; + +public class StoreRatePreviewResult +{ + public string CurrencyPair { get; set; } + public decimal? Rate { get; set; } + public List Errors { get; set; } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/StoreRateResult.cs b/BTCPayServer.Client/Models/StoreRateResult.cs new file mode 100644 index 000000000..d44bc1ea6 --- /dev/null +++ b/BTCPayServer.Client/Models/StoreRateResult.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Client.Models; + +public class StoreRateResult +{ + public string CurrencyPair { get; set; } + public decimal Rate { get; set; } +} \ No newline at end of file diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index afc7edb60..970c6b72e 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2701,8 +2701,67 @@ namespace BTCPayServer.Tests Assert.NotNull(custodians); Assert.NotEmpty(custodians); } + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task StoreRateConfigTests() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri); + await AssertHttpError(401, async () => await unauthClient.GetRateSources()); + var user = tester.NewAccount(); + await user.GrantAccessAsync(); + var clientBasic = await user.CreateClient(); + Assert.NotEmpty(await clientBasic.GetRateSources()); + var config = await clientBasic.GetStoreRateConfiguration(user.StoreId); + Assert.NotNull(config); + Assert.False(config.IsCustomScript); + Assert.Equal("X_X = coingecko(X_X);", config.EffectiveScript); + Assert.Equal("coingecko", config.PreferredSource); + Assert.Equal(0.9m, + Assert.Single(await clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, + new StoreRateConfiguration() {IsCustomScript = true, EffectiveScript = "BTC_XYZ = 1;", Spread = 10m,}, + new[] {"BTC_XYZ"})).Rate); + + Assert.True((await clientBasic.UpdateStoreRateConfiguration(user.StoreId, + new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "BTC_XYZ = 1", Spread = 10m,})) + .IsCustomScript); + + config = await clientBasic.GetStoreRateConfiguration(user.StoreId); + Assert.NotNull(config); + Assert.NotNull(config.EffectiveScript); + Assert.Equal("BTC_XYZ = 1;", config.EffectiveScript); + Assert.Equal(10m, config.Spread); + Assert.Null(config.PreferredSource); + + Assert.NotNull((await clientBasic.GetStoreRateConfiguration(user.StoreId)).EffectiveScript); + Assert.NotNull((await clientBasic.UpdateStoreRateConfiguration(user.StoreId, + new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko"})) + .PreferredSource); + + config = await clientBasic.GetStoreRateConfiguration(user.StoreId); + Assert.Equal("X_X = coingecko(X_X);", config.EffectiveScript); + + await AssertValidationError(new[] { "EffectiveScript", "PreferredSource" }, () => + clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, EffectiveScript = "BTC_XYZ = 1;" })); + + await AssertValidationError(new[] { "EffectiveScript" }, () => +clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "BTC_XYZ rg8w*# 1;" })); + await AssertValidationError(new[] { "PreferredSource" }, () => +clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "", PreferredSource = "coingecko" })); + + await AssertValidationError(new[] { "PreferredSource", "Spread" }, () => +clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO", Spread = -1m })); + + await AssertValidationError(new[] { "currencyPair" }, () => +clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko" }, new[] { "BTC_USD_USD_BTC" })); + await AssertValidationError(new[] { "PreferredSource", "currencyPair" }, () => +clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO" }, new[] { "BTC_USD_USD_BTC" })); + } + [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CustodianAccountControllerTests() diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs new file mode 100644 index 000000000..bae690109 --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRatesConfigurationController.cs @@ -0,0 +1,204 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Rating; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using RateSource = BTCPayServer.Client.Models.RateSource; + +namespace BTCPayServer.Controllers.GreenField +{ + [ApiController] + [Route("api/v1/stores/{storeId}/rates/configuration")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldStoreRateConfigurationController : ControllerBase + { + private readonly RateFetcher _rateProviderFactory; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly StoreRepository _storeRepository; + + public GreenfieldStoreRateConfigurationController( + RateFetcher rateProviderFactory, + BTCPayNetworkProvider btcPayNetworkProvider, + StoreRepository storeRepository) + { + _rateProviderFactory = rateProviderFactory; + _btcPayNetworkProvider = btcPayNetworkProvider; + _storeRepository = storeRepository; + } + + [HttpGet("")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public IActionResult GetStoreRateConfiguration() + { + var data = HttpContext.GetStoreData(); + var blob = data.GetStoreBlob(); + + return Ok(new StoreRateConfiguration() + { + EffectiveScript = blob.GetRateRules(_btcPayNetworkProvider, out var preferredExchange).ToString(), + Spread = blob.Spread * 100.0m, + IsCustomScript = blob.RateScripting, + PreferredSource = preferredExchange ? blob.PreferredExchange : null + }); + } + + [HttpGet("/misc/rate-sources")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie + "," + AuthenticationSchemes.Greenfield)] + public ActionResult> GetRateSources() + { + return Ok(_rateProviderFactory.RateProviderFactory.GetSupportedExchanges().Select(provider => + new RateSource() {Id = provider.Id, Name = provider.DisplayName})); + } + + [HttpPut("")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task UpdateStoreRateConfiguration( + StoreRateConfiguration configuration) + { + var storeData = HttpContext.GetStoreData(); + var blob = storeData.GetStoreBlob(); + ValidateAndSanitizeConfiguration(configuration, blob); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + PopulateBlob(configuration, blob); + + storeData.SetStoreBlob(blob); + + await _storeRepository.UpdateStore(storeData); + + + return GetStoreRateConfiguration(); + } + + [HttpPost("preview")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task PreviewUpdateStoreRateConfiguration( + StoreRateConfiguration configuration, [FromQuery] string[] currencyPair) + { + var data = HttpContext.GetStoreData(); + var blob = data.GetStoreBlob(); + var parsedCurrencyPairs = new HashSet(); + + + foreach (var pair in currencyPair ?? Array.Empty()) + { + if (!CurrencyPair.TryParse(pair, out var currencyPairParsed)) + { + ModelState.AddModelError(nameof(currencyPair), + $"Invalid currency pair '{pair}' (it should be formatted like BTC_USD,BTC_CAD)"); + break; + } + + parsedCurrencyPairs.Add(currencyPairParsed); + } + ValidateAndSanitizeConfiguration(configuration, blob); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + PopulateBlob(configuration, blob); + + var rules = blob.GetRateRules(_btcPayNetworkProvider); + + + var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, CancellationToken.None); + await Task.WhenAll(rateTasks.Values); + var result = new List(); + foreach (var rateTask in rateTasks) + { + var rateTaskResult = rateTask.Value.Result; + + result.Add(new StoreRatePreviewResult() + { + CurrencyPair = rateTask.Key.ToString(), + Errors = rateTaskResult.Errors.Select(errors => errors.ToString()).ToList(), + Rate = rateTaskResult.Errors.Any() ? (decimal?)null : rateTaskResult.BidAsk.Bid + }); + } + + return Ok(result); + } + + private void ValidateAndSanitizeConfiguration(StoreRateConfiguration? configuration, StoreBlob storeBlob) + { + if (configuration is null) + { + ModelState.AddModelError("", "Body required"); + return; + } + if (configuration.Spread < 0 || configuration.Spread > 100) + { + ModelState.AddModelError(nameof(configuration.Spread), + $"Spread value must be in %, between 0 and 100"); + } + + if (configuration.IsCustomScript) + { + if (string.IsNullOrEmpty(configuration.EffectiveScript)) + { + configuration.EffectiveScript = storeBlob.GetDefaultRateRules(_btcPayNetworkProvider).ToString(); + } + + if (!RateRules.TryParse(configuration.EffectiveScript, out var r)) + { + ModelState.AddModelError(nameof(configuration.EffectiveScript), + $"Script syntax is invalid"); + } + else + { + configuration.EffectiveScript = r.ToString(); + } + + if (!string.IsNullOrEmpty(configuration.PreferredSource)) + { + ModelState.AddModelError(nameof(configuration.PreferredSource), +$"You can't set the preferredSource if you are using custom scripts"); + } + } + else + { + if (!string.IsNullOrEmpty(configuration.EffectiveScript)) + { + ModelState.AddModelError(nameof(configuration.EffectiveScript), + $"You can't set the effectiveScript if you aren't using custom scripts"); + } + if (string.IsNullOrEmpty(configuration.PreferredSource)) + { + ModelState.AddModelError(nameof(configuration.PreferredSource), +$"The preferredSource is required if you aren't using custom scripts"); + } + + configuration.PreferredSource = _rateProviderFactory + .RateProviderFactory + .GetSupportedExchanges() + .FirstOrDefault(s => + s.Id.Equals(configuration.PreferredSource, + StringComparison.InvariantCultureIgnoreCase))?.Id; + + if (string.IsNullOrEmpty(configuration.PreferredSource)) + { + ModelState.AddModelError(nameof(configuration.PreferredSource), + $"Unsupported source, please check /misc/rate-sources to see valid values ({configuration.PreferredSource})"); + } + } + } + + private static void PopulateBlob(StoreRateConfiguration configuration, StoreBlob storeBlob) + { + storeBlob.PreferredExchange = configuration.PreferredSource; + storeBlob.Spread = configuration.Spread / 100.0m; + storeBlob.RateScripting = configuration.IsCustomScript; + storeBlob.RateScript = configuration.EffectiveScript; + } + } +} diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 6de596ba1..1541a783c 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -1121,5 +1121,31 @@ namespace BTCPayServer.Controllers.Greenfield { HandleActionResult(await GetController().DeleteApp(appId)); } + + public override Task> GetRateSources(CancellationToken token = default) + { + return Task.FromResult(GetFromActionResult(GetController().GetRateSources())); + } + + public override Task GetStoreRateConfiguration(string storeId, CancellationToken token = default) + { + return Task.FromResult(GetFromActionResult(GetController().GetStoreRateConfiguration())); + } + + public override async Task> PreviewUpdateStoreRateConfiguration(string storeId, + StoreRateConfiguration request, + string[] currencyPair, + CancellationToken token = default) + { + return GetFromActionResult>( + await GetController().PreviewUpdateStoreRateConfiguration(request, + currencyPair)); + } + + public override async Task UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, CancellationToken token = default) + { + return GetFromActionResult(await GetController().UpdateStoreRateConfiguration(request)); + } + } } diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 772d0a699..e3c64dfcc 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -131,15 +131,21 @@ namespace BTCPayServer.Data public double PaymentTolerance { get; set; } public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider) + { + return GetRateRules(networkProvider, out _); + } + public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider, out bool preferredSource) { if (!RateScripting || string.IsNullOrEmpty(RateScript) || !BTCPayServer.Rating.RateRules.TryParse(RateScript, out var rules)) { + preferredSource = true; return GetDefaultRateRules(networkProvider); } else { + preferredSource = false; rules.Spread = Spread; return rules; } diff --git a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs index 4f4db8a12..f26bb1101 100644 --- a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs +++ b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs @@ -1,3 +1,5 @@ +#nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; @@ -9,6 +11,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; namespace BTCPayServer.Security.Greenfield { @@ -31,7 +34,25 @@ namespace BTCPayServer.Security.Greenfield _identityOptions = identityOptions; _userManager = userManager; } - + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // This one deserve some explanation... + // Some routes have this authorization. + // [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie + "," + AuthenticationSchemes.Greenfield)] + // This is meant for API routes that we wish to access by greenfield but also via the browser for documentation purpose (say /misc/rate-sources) + // Now, if we aren't logged nor authenticated via greenfield, the AuthenticationHandlers get challenged. + // The last handler to be challenged is the CookieAuthenticationHandler, which instruct to handle the challenge as a redirection to + // the login page. + // But this isn't what we want when we call the API programmatically, instead we want an error 401 with a json error message. + // This hack modify a request's header to trick the CookieAuthenticationHandler to not do a redirection. + if (!Request.Headers.Accept.Any(s => s.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))) + Request.Headers.XRequestedWith = new Microsoft.Extensions.Primitives.StringValues("XMLHttpRequest"); + return base.HandleChallengeAsync(properties); + } + private bool IsJson(string contentType) + { + return contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) is true; + } protected override async Task HandleAuthenticateAsync() { if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey)) diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.misc.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.misc.json index 6a6cfe6b3..1311f4a22 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.misc.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.misc.json @@ -1,5 +1,42 @@ { "paths": { + "/misc/rate-sources": { + "get": { + "tags": [ + "Miscelleneous" + ], + "summary": "Get available rate sources", + "description": "View available rate providers that you can use in stores", + "operationId": "GetRateSources", + "responses": { + "200": { + "description": "rate providers array", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The id of the rate provider" + }, + "name": { + "type": "string", + "description": "The name of the rate provider" + } + } + } + } + } + } + } + }, + "security": [] + } + }, "/misc/permissions": { "get": { "tags": [ diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-rates-config.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-rates-config.json new file mode 100644 index 000000000..0d0bdbdd3 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-rates-config.json @@ -0,0 +1,252 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/rates/configuration": { + "get": { + "tags": [ + "Stores (Rates Config)" + ], + "summary": "Get store rate settings", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "View rate settings of the specified store", + "operationId": "Stores_GetStoreRateConfiguration", + "responses": { + "200": { + "description": "specified store rate settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreRateConfiguration" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "put": { + "tags": [ + "Stores (Rates Config)" + ], + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "summary": "Update store rate settings", + "description": "Update a store's rate settings", + "operationId": "Stores_UpdateStoreRateConfiguration", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreRateConfiguration" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "The settings were updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreRateConfiguration" + } + } + } + }, + "400": { + "description": "A list of errors that occurred when updating the settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to modify the store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/rates/configuration/preview": { + "post": { + "tags": [ + "Stores (Rates Config)" + ], + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "currencyPair", + "description": "The currency pairs to preview", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "x-position": 1 + } + ], + "summary": "Preview rate configuration results", + "description": "Preview rate configuration results before you set it on the store", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreRateConfiguration" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "The settings were executed and a preview was returned", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoreRatePreviewResult" + } + } + } + } + }, + "400": { + "description": "A list of errors that occurred when previewing the settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to modify the store" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "StoreRateConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "spread": { + "type": "string", + "description": "A spread applies to the rate fetched in `%`. Must be `>= 0` or `<= 100`" + }, + "preferredSource": { + "type": "string", + "description": "If `isCustomerScript` is `false` affect use this source in the default's `effectiveScript`, if `isCustomerScript` is `false`, this setting is set to `null`. (See /misc/rate-sources for the available sources)" + }, + "isCustomScript": { + "type": "boolean", + "description": "Whether to use `preferredSource` with default script or a custom `effectiveScript`." + }, + "effectiveScript": { + "type": "string", + "description": "When `isCustomScript` is `true`, this represent the custom script used to calculate a currency pair's exchange rate. Else, it represent the script generated by the default rules and `preferredSource`." + } + } + }, + "StoreRatePreviewResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "currencyPair": { + "type": "string", + "description": "Currency pair in the format of BTC_USD" + }, + "errors": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Errors relating to this currency pair fetching based on your config" + }, + "rate": { + "type": "string", + "description": "the rate fetched based on th currency pair" + } + } + } + } + }, + "tags": [ + { + "name": "Stores (Rates Config)" + } + ] +} +