Implement store templates (#6704)

* Implement store templates

* Use the template for the default rate rules

* Polish messages

* Do not show exchange selection if template has a script
This commit is contained in:
Nicolas Dorier
2025-05-09 15:58:24 +09:00
committed by GitHub
parent 0fbff219d2
commit 1e79730c6e
18 changed files with 421 additions and 124 deletions

View File

@@ -17,7 +17,7 @@ namespace BTCPayServer.Client.Models
public string Website { get; set; }
public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; }
public bool? ApplyBrandColorToBackend { get; set; }
public string LogoUrl { get; set; }
public string CssUrl { get; set; }
public string PaymentSoundUrl { get; set; }
@@ -26,56 +26,56 @@ namespace BTCPayServer.Client.Models
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);
public TimeSpan? InvoiceExpiration { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan DisplayExpirationTimer { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan? DisplayExpirationTimer { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
[JsonProperty("refundBOLT11Expiration", NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan RefundBOLT11Expiration { get; set; } = TimeSpan.FromDays(30);
public TimeSpan? RefundBOLT11Expiration { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60);
public TimeSpan? MonitoringExpiration { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public SpeedPolicy SpeedPolicy { get; set; }
public SpeedPolicy? SpeedPolicy { get; set; }
public string LightningDescriptionTemplate { get; set; }
public double PaymentTolerance { get; set; } = 0;
public bool AnyoneCanCreateInvoice { get; set; }
public double? PaymentTolerance { get; set; }
public bool? AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { get; set; }
public bool LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; }
public bool OnChainWithLnInvoiceFallback { get; set; }
public bool LazyPaymentMethods { get; set; }
public bool RedirectAutomatically { get; set; }
public bool? LightningAmountInSatoshi { get; set; }
public bool? LightningPrivateRouteHints { get; set; }
public bool? OnChainWithLnInvoiceFallback { get; set; }
public bool? LazyPaymentMethods { get; set; }
public bool? RedirectAutomatically { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool Archived { get; set; }
public bool? Archived { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool ShowRecommendedFee { get; set; } = true;
public bool? ShowRecommendedFee { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int RecommendedFeeBlockTarget { get; set; } = 1;
public int? RecommendedFeeBlockTarget { get; set; }
public string DefaultPaymentMethod { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string DefaultLang { get; set; } = "en";
public string DefaultLang { get; set; }
public string HtmlTitle { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public NetworkFeeMode NetworkFeeMode { get; set; } = NetworkFeeMode.Never;
public NetworkFeeMode? NetworkFeeMode { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public List<PaymentMethodCriteriaData> PaymentMethodCriteria { get; set; }
public bool PayJoinEnabled { get; set; }
public bool? PayJoinEnabled { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool? AutoDetectLanguage { get; set; }

View File

@@ -0,0 +1,45 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250501000000_storetemplate")]
public partial class storetemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// This migrates the old `Default Currency` settings from Server Settings to a Default Store Template.
migrationBuilder.Sql(
"""
UPDATE "Settings"
SET "Value" =
jsonb_set(
-- remove "DefaultCurrency", create a template
("Value" - 'DefaultCurrency') || '{"DefaultStoreTemplate":{"blob":{}}}'::JSONB,
-- path to insert the new nested value
'{DefaultStoreTemplate,blob,defaultCurrency}',
-- extract the old value of "DefaultCurrency"
to_jsonb("Value"->'DefaultCurrency'),
true
)
WHERE "Id" = 'BTCPayServer.Services.PoliciesSettings'
AND "Value" ? 'DefaultCurrency' AND "Value"->>'DefaultCurrency' != 'USD';
UPDATE "Settings"
SET "Value" = "Value" - 'DefaultCurrency'
WHERE "Id" = 'BTCPayServer.Services.PoliciesSettings'
AND "Value" ? 'DefaultCurrency';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -5,4 +5,4 @@ public enum RateSource
Coingecko,
Direct
}
public record RateSourceInfo(string Id, string DisplayName, string Url, RateSource Source = RateSource.Direct);
public record RateSourceInfo(string? Id, string DisplayName, string Url, RateSource Source = RateSource.Direct);

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -15,7 +14,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Playwright;
using NBitcoin;
using NBitcoin.RPC;
using OpenQA.Selenium;
using Xunit;
namespace BTCPayServer.Tests
@@ -42,8 +40,6 @@ namespace BTCPayServer.Tests
await Server.StartAsync();
var builder = new ConfigurationBuilder();
builder.AddUserSecrets("AB0AC1DD-9D26-485B-9416-56A33F268117");
var config = builder.Build();
var playwright = await Playwright.CreateAsync();
Browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
@@ -93,7 +89,7 @@ namespace BTCPayServer.Tests
}
else
{
await GoToUrl(storeId == null ? "/invoices/" : $"/stores/{storeId}/invoices/");
await GoToUrl($"/stores/{storeId}/invoices/");
StoreId = storeId;
}
}
@@ -123,9 +119,9 @@ namespace BTCPayServer.Tests
await Page.SelectOptionAsync("select[name='DefaultPaymentMethod']", new SelectOptionValue { Value = defaultPaymentMethod });
await ClickPagePrimary();
var statusText = (await FindAlertMessage(expectedSeverity)).TextContentAsync();
var statusText = await (await FindAlertMessage(expectedSeverity)).TextContentAsync();
var inv = expectedSeverity == StatusMessageModel.StatusSeverity.Success
? Regex.Match(await statusText, @"Invoice (\w+) just created!").Groups[1].Value
? Regex.Match(statusText!, @"Invoice (\w+) just created!").Groups[1].Value
: null;
InvoiceId = inv;
@@ -225,20 +221,28 @@ namespace BTCPayServer.Tests
return new TestAccount(Server) { StoreId = StoreId, Email = CreatedUser, Password = Password, RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser }, IsAdmin = IsAdmin };
}
public async Task<(string storeName, string storeId)> CreateNewStore(bool keepId = true)
public async Task<(string storeName, string storeId)> CreateNewStore(bool keepId = true, string preferredExchange = "CoinGecko")
{
if (!Page.Url.EndsWith("stores/create"))
{
if (await Page.Locator("#StoreSelectorToggle").IsVisibleAsync())
{
await Page.Locator("#StoreSelectorToggle").ClickAsync();
await Page.ClickAsync("#StoreSelectorToggle");
await Page.ClickAsync("#StoreSelectorCreate");
}
else
{
await GoToUrl("/stores/create");
}
}
var name = "Store" + RandomUtils.GetUInt64();
TestLogs.LogInformation($"Created store {name}");
await Page.FillAsync("#Name", name);
var selectedOption = await Page.Locator("#PreferredExchange option:checked").TextContentAsync();
Assert.Equal("Recommendation (Kraken)", selectedOption.Trim());
await Page.Locator("#PreferredExchange").SelectOptionAsync(new SelectOptionValue { Label = "CoinGecko" });
Assert.Equal("Recommendation (Kraken)", selectedOption?.Trim());
await Page.Locator("#PreferredExchange").SelectOptionAsync(new SelectOptionValue { Label = preferredExchange });
await Page.ClickAsync("#Create");
await Page.ClickAsync("#StoreNav-General");
var storeId = await Page.InputValueAsync("#Id");
@@ -471,7 +475,7 @@ namespace BTCPayServer.Tests
walletId ??= WalletId;
await GoToWallet(walletId, WalletsNavPages.Receive);
var addressStr = await Page.Locator("#Address").GetAttributeAsync("data-text");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
for (var i = 0; i < coins; i++)
{
bool mined = false;

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Net;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -12,6 +13,7 @@ using BTCPayServer.Views.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.Playwright;
using NBitcoin;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
@@ -38,7 +40,6 @@ namespace BTCPayServer.Tests
Assert.Contains("Starting listening NBXplorer", await s.Page.ContentAsync());
}
[Fact]
public async Task CanUseForms()
{
@@ -489,5 +490,92 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Configured", await s.Page.ContentAsync());
Assert.Contains("test_fix", await s.Page.ContentAsync());
}
[Fact]
public async Task CanUseStoreTemplate()
{
await using var s = CreatePlaywrightTester(newDb: true);
await s.StartAsync();
await s.RegisterNewUser(true);
await s.CreateNewStore(preferredExchange: "Kraken");
var client = await s.AsTestAccount().CreateClient();
await client.UpdateStore(s.StoreId, new UpdateStoreRequest()
{
Name = "Can Use Store?",
Website = "https://test.com/",
CelebratePayment = false,
DefaultLang = "fr-FR",
NetworkFeeMode = NetworkFeeMode.MultiplePaymentsOnly,
ShowStoreHeader = false
});
await s.GoToServer();
await s.Page.ClickAsync("#SetTemplate");
await s.FindAlertMessage();
var newStore = await client.CreateStore(new ());
Assert.Equal("Can Use Store?", newStore.Name);
Assert.Equal("https://test.com/", newStore.Website);
Assert.False(newStore.CelebratePayment);
Assert.Equal("fr-FR", newStore.DefaultLang);
Assert.Equal(NetworkFeeMode.MultiplePaymentsOnly, newStore.NetworkFeeMode);
Assert.False(newStore.ShowStoreHeader);
newStore = await client.CreateStore(new (){ Name = "Yes you can also customize"});
Assert.Equal("Yes you can also customize", newStore.Name);
Assert.Equal("https://test.com/", newStore.Website);
Assert.False(newStore.CelebratePayment);
Assert.Equal("fr-FR", newStore.DefaultLang);
Assert.Equal(NetworkFeeMode.MultiplePaymentsOnly, newStore.NetworkFeeMode);
Assert.False(newStore.ShowStoreHeader);
await s.GoToUrl("/stores/create");
Assert.Equal("Can Use Store?" ,await s.Page.InputValueAsync("#Name"));
await s.Page.FillAsync("#Name", "Just changed it!");
await s.Page.ClickAsync("#Create");
await s.Page.ClickAsync("#StoreNav-General");
var newStoreId = await s.Page.InputValueAsync("#Id");
Assert.NotEqual(newStoreId, s.StoreId);
newStore = await client.GetStore(newStoreId);
Assert.Equal("Just changed it!", newStore.Name);
Assert.Equal("https://test.com/", newStore.Website);
Assert.False(newStore.CelebratePayment);
Assert.Equal("fr-FR", newStore.DefaultLang);
Assert.Equal(NetworkFeeMode.MultiplePaymentsOnly, newStore.NetworkFeeMode);
Assert.False(newStore.ShowStoreHeader);
await s.GoToServer();
await s.Page.ClickAsync("#ResetTemplate");
await s.FindAlertMessage(partialText: "Store template successfully unset");
await s.GoToUrl("/stores/create");
Assert.Equal("" ,await s.Page.InputValueAsync("#Name"));
newStore = await client.CreateStore(new (){ Name = "Test"});
Assert.Equal(TimeSpan.FromDays(30), newStore.RefundBOLT11Expiration);
Assert.Equal(TimeSpan.FromDays(1), newStore.MonitoringExpiration);
Assert.Equal(TimeSpan.FromMinutes(5), newStore.DisplayExpirationTimer);
Assert.Equal(TimeSpan.FromMinutes(15), newStore.InvoiceExpiration);
// What happens if the default template doesn't have all the fields?
var settings = s.Server.PayTester.GetService<SettingsRepository>();
var policies = await settings.GetSettingAsync<PoliciesSettings>() ?? new();
policies.DefaultStoreTemplate = new JObject()
{
["blob"] = new JObject()
{
["defaultCurrency"] = "AAA",
["defaultLang"] = "de-DE"
}
};
await settings.UpdateSetting(policies);
newStore = await client.CreateStore(new() { Name = "Test2"});
Assert.Equal("AAA", newStore.DefaultCurrency);
Assert.Equal("de-DE", newStore.DefaultLang);
Assert.Equal(TimeSpan.FromDays(30), newStore.RefundBOLT11Expiration);
Assert.Equal(TimeSpan.FromDays(1), newStore.MonitoringExpiration);
Assert.Equal(TimeSpan.FromMinutes(5), newStore.DisplayExpirationTimer);
Assert.Equal(TimeSpan.FromMinutes(15), newStore.InvoiceExpiration);
}
}
}

View File

@@ -1449,7 +1449,8 @@ namespace BTCPayServer.Tests
var script = await tester.Page.InputValueAsync($"#{source}_Script");
var defaultScript = await tester.Page.GetAttributeAsync($"#{source}_DefaultScript", "data-defaultScript");
Assert.Equal(script, defaultScript);
Assert.Contains("X_JPY = bitbank(X_JPY);", defaultScript);
Assert.Contains("X_TRY = btcturk(X_TRY);", defaultScript);
await Test("BTC_JPY");
rules = await tester.Page.Locator(".testresult .testresult_rule").AllAsync();

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Amazon.Runtime.Internal;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
@@ -19,7 +19,9 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using static System.Runtime.InteropServices.JavaScript.JSType;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield
@@ -34,12 +36,14 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly UserManager<ApplicationUser> _userManager;
private readonly IFileService _fileService;
private readonly UriResolver _uriResolver;
private readonly JsonSerializerSettings _serializedSettings;
public GreenfieldStoresController(
StoreRepository storeRepository,
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
IFileService fileService,
IOptions<MvcNewtonsoftJsonOptions> jsonOptions,
UriResolver uriResolver)
{
_storeRepository = storeRepository;
@@ -47,6 +51,7 @@ namespace BTCPayServer.Controllers.Greenfield
_userManager = userManager;
_fileService = fileService;
_uriResolver = uriResolver;
_serializedSettings = jsonOptions.Value.SerializerSettings;
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -77,7 +82,7 @@ namespace BTCPayServer.Controllers.Greenfield
var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound();
await _storeRepository.RemoveStore(storeId, _userManager.GetUserId(User));
await _storeRepository.RemoveStore(storeId, _userManager.GetUserId(User) ?? "");
return Ok();
}
@@ -85,27 +90,39 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettingsUnscoped, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateStore(CreateStoreRequest request)
{
var store = await _storeRepository.GetDefaultStoreTemplate();
request = await MergeStoreRequestWithTemplate(request, store);
var validationResult = Validate(request);
if (validationResult != null) return validationResult;
var store = new StoreData();
PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId);
ToModel(request, store, defaultPaymentMethodId);
await _storeRepository.CreateStore(_userManager.GetUserId(User), store);
ToModel(request, store);
await _storeRepository.CreateStore(_userManager.GetUserId(User) ?? "", store);
return Ok(await FromModel(store));
}
private async Task<T> MergeStoreRequestWithTemplate<T>(T request, StoreData store) where T: StoreBaseData
{
var serializer = JsonSerializer.Create(_serializedSettings);
var requestRaw = JObject.FromObject(request, serializer);
var templateRaw = JObject.FromObject(await FromModel(store), serializer);
templateRaw.Merge(requestRaw,
new()
{
MergeNullValueHandling = MergeNullValueHandling.Ignore,
MergeArrayHandling = MergeArrayHandling.Replace
});
return templateRaw.ToObject<T>(serializer);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}")]
public async Task<IActionResult> UpdateStore(string storeId, UpdateStoreRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound();
request = await MergeStoreRequestWithTemplate(request, store);
var validationResult = Validate(request);
if (validationResult != null) return validationResult;
PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId);
ToModel(request, store, defaultPaymentMethodId);
ToModel(request, store);
await _storeRepository.UpdateStore(store);
return Ok(await FromModel(store));
}
@@ -223,13 +240,16 @@ namespace BTCPayServer.Controllers.Greenfield
};
}
private void ToModel(StoreBaseData restModel, StoreData model, PaymentMethodId defaultPaymentMethod)
[SuppressMessage("ReSharper", "PossibleInvalidOperationException")]
private void ToModel(StoreBaseData restModel, StoreData model)
{
// Dereference on .Value is fine. Those values should be set by the template
var blob = model.GetStoreBlob();
model.StoreName = restModel.Name;
model.StoreWebsite = restModel.Website;
model.Archived = restModel.Archived;
model.SpeedPolicy = restModel.SpeedPolicy;
model.Archived = restModel.Archived.Value;
model.SpeedPolicy = restModel.SpeedPolicy.Value;
PaymentMethodId.TryParse(restModel.DefaultPaymentMethod, out var defaultPaymentMethod);
model.SetDefaultPaymentId(defaultPaymentMethod);
//we do not include the default payment method in this model and instead opt to set it in the stores/storeid/payment-methods endpoints
//blob
@@ -237,31 +257,31 @@ namespace BTCPayServer.Controllers.Greenfield
//we do not include ExcludedPaymentMethods in this model and instead opt to set it in stores/storeid/payment-methods endpoints
//we do not include EmailSettings in this model and instead opt to set it in stores/storeid/email endpoints
//we do not include OnChainMinValue and LightningMaxValue because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
blob.NetworkFeeMode = restModel.NetworkFeeMode;
blob.NetworkFeeMode = restModel.NetworkFeeMode.Value;
blob.DefaultCurrency = restModel.DefaultCurrency;
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints;
blob.OnChainWithLnInvoiceFallback = restModel.OnChainWithLnInvoiceFallback;
blob.LazyPaymentMethods = restModel.LazyPaymentMethods;
blob.RedirectAutomatically = restModel.RedirectAutomatically;
blob.ShowRecommendedFee = restModel.ShowRecommendedFee;
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget;
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi.Value;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints.Value;
blob.OnChainWithLnInvoiceFallback = restModel.OnChainWithLnInvoiceFallback.Value;
blob.LazyPaymentMethods = restModel.LazyPaymentMethods.Value;
blob.RedirectAutomatically = restModel.RedirectAutomatically.Value;
blob.ShowRecommendedFee = restModel.ShowRecommendedFee.Value;
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget.Value;
blob.DefaultLang = restModel.DefaultLang;
blob.StoreSupportUrl = restModel.SupportUrl;
blob.MonitoringExpiration = restModel.MonitoringExpiration;
blob.InvoiceExpiration = restModel.InvoiceExpiration;
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer;
blob.MonitoringExpiration = restModel.MonitoringExpiration.Value;
blob.InvoiceExpiration = restModel.InvoiceExpiration.Value;
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer.Value;
blob.HtmlTitle = restModel.HtmlTitle;
blob.AnyoneCanInvoice = restModel.AnyoneCanCreateInvoice;
blob.AnyoneCanInvoice = restModel.AnyoneCanCreateInvoice.Value;
blob.LightningDescriptionTemplate = restModel.LightningDescriptionTemplate;
blob.PaymentTolerance = restModel.PaymentTolerance;
blob.PayJoinEnabled = restModel.PayJoinEnabled;
blob.PaymentTolerance = restModel.PaymentTolerance.Value;
blob.PayJoinEnabled = restModel.PayJoinEnabled.Value;
blob.BrandColor = restModel.BrandColor;
blob.ApplyBrandColorToBackend = restModel.ApplyBrandColorToBackend;
blob.ApplyBrandColorToBackend = restModel.ApplyBrandColorToBackend.Value;
blob.LogoUrl = restModel.LogoUrl is null ? null : UnresolvedUri.Create(restModel.LogoUrl);
blob.CssUrl = restModel.CssUrl is null ? null : UnresolvedUri.Create(restModel.CssUrl);
blob.RefundBOLT11Expiration = restModel.RefundBOLT11Expiration;
blob.RefundBOLT11Expiration = restModel.RefundBOLT11Expiration.Value;
blob.PaymentSoundUrl = restModel.PaymentSoundUrl is null ? null : UnresolvedUri.Create(restModel.PaymentSoundUrl);
if (restModel.AutoDetectLanguage.HasValue)
blob.AutoDetectLanguage = restModel.AutoDetectLanguage.Value;
@@ -336,24 +356,25 @@ namespace BTCPayServer.Controllers.Greenfield
{
for (int index = 0; index < request.PaymentMethodCriteria.Count; index++)
{
var i = index;
PaymentMethodCriteriaData pmc = request.PaymentMethodCriteria[index];
if (string.IsNullOrEmpty(pmc.CurrencyCode))
{
request.AddModelError(data => data.PaymentMethodCriteria[index].CurrencyCode, "CurrencyCode is required", this);
request.AddModelError(data => data.PaymentMethodCriteria[i].CurrencyCode, "CurrencyCode is required", this);
}
else if (_currencyNameTable.GetCurrencyData(pmc.CurrencyCode, false) is null)
{
request.AddModelError(data => data.PaymentMethodCriteria[index].CurrencyCode, "CurrencyCode is invalid", this);
request.AddModelError(data => data.PaymentMethodCriteria[i].CurrencyCode, "CurrencyCode is invalid", this);
}
if (string.IsNullOrEmpty(pmc.PaymentMethodId) || PaymentMethodId.TryParse(pmc.PaymentMethodId) is null)
{
request.AddModelError(data => data.PaymentMethodCriteria[index].PaymentMethodId, "Payment method was invalid", this);
request.AddModelError(data => data.PaymentMethodCriteria[i].PaymentMethodId, "Payment method was invalid", this);
}
if (pmc.Amount < 0)
{
request.AddModelError(data => data.PaymentMethodCriteria[index].Amount, "Amount must be greater than 0", this);
request.AddModelError(data => data.PaymentMethodCriteria[i].Amount, "Amount must be greater than 0", this);
}
}
}

View File

@@ -351,6 +351,34 @@ namespace BTCPayServer.Controllers
{
await UpdateViewBag();
if (command == "ResetTemplate")
{
ModelState.Clear();
await _StoreRepository.SetDefaultStoreTemplate(null);
this.TempData.SetStatusSuccess(StringLocalizer["Store template successfully unset"]);
return RedirectToAction(nameof(Policies));
}
if (command == "SetTemplate")
{
ModelState.Clear();
var storeId = this.HttpContext.GetStoreData()?.Id;
if (storeId is null)
{
this.TempData.SetStatusMessageModel(new()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = StringLocalizer["You need to select a store first"]
});
}
else
{
await _StoreRepository.SetDefaultStoreTemplate(storeId, GetUserId());
this.TempData.SetStatusSuccess(StringLocalizer["Store template created from store '{0}'. New stores will inherit these settings.", HttpContext.GetStoreData().StoreName]);
}
return RedirectToAction(nameof(Policies));
}
if (command == "add-domain")
{
ModelState.Clear();

View File

@@ -72,8 +72,18 @@ public partial class UIStoresController
{
var isFallback = command is "scripting-toggle-fallback";
var rateSettings = storeBlob.GetOrCreateRateSettings(isFallback);
rateSettings.RateScripting = !rateSettings.RateScripting;
if (!rateSettings.RateScripting)
{
rateSettings.RateScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString();
rateSettings.RateScripting = true;
}
else
{
rateSettings.RateScripting = false;
rateSettings.RateScript = null;
}
CurrentStore.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(CurrentStore);
if (rateSettings.RateScripting)
@@ -179,7 +189,7 @@ public partial class UIStoresController
}
}
private void SetViewModel(RatesViewModel.Source vm, StoreBlob.RateSettings? rateSettings, StoreBlob storeBlob)
private async Task SetViewModel(RatesViewModel.Source vm, StoreBlob.RateSettings? rateSettings, StoreBlob storeBlob)
{
if (rateSettings is null)
return;
@@ -191,7 +201,9 @@ public partial class UIStoresController
vm.PreferredResolvedExchange = chosenSource.Id;
vm.RateSource = chosenSource.Url;
vm.Script = rateSettings.GetRateRules(_defaultRules, storeBlob.Spread).ToString();
vm.DefaultScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString();
var defaultRateSettings = (await _storeRepo.GetDefaultStoreTemplate()).GetStoreBlob()?.GetRateSettings(false) ?? new();
vm.DefaultScript = defaultRateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString();
vm.ShowScripting = rateSettings.RateScripting;
vm.ScriptingConfirm = new()

View File

@@ -24,7 +24,6 @@ namespace BTCPayServer.Controllers
{
private readonly StoreRepository _repo;
private readonly IStringLocalizer StringLocalizer;
private readonly SettingsRepository _settingsRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly DefaultRulesCollection _defaultRules;
private readonly RateFetcher _rateFactory;
@@ -35,15 +34,13 @@ namespace BTCPayServer.Controllers
DefaultRulesCollection defaultRules,
StoreRepository storeRepository,
IStringLocalizer stringLocalizer,
RateFetcher rateFactory,
SettingsRepository settingsRepository)
RateFetcher rateFactory)
{
_repo = storeRepository;
StringLocalizer = stringLocalizer;
_userManager = userManager;
_defaultRules = defaultRules;
_rateFactory = rateFactory;
_settingsRepository = settingsRepository;
}
[HttpGet]
@@ -71,12 +68,16 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> CreateStore(bool skipWizard)
{
var stores = await _repo.GetStoresByUserId(GetUserId());
var defaultCurrency = (await _settingsRepository.GetSettingAsync<PoliciesSettings>())?.DefaultCurrency ?? StoreBlob.StandardDefaultCurrency;
var defaultTemplate = await _repo.GetDefaultStoreTemplate();
var blob = defaultTemplate.GetStoreBlob();
var vm = new CreateStoreViewModel
{
Name = defaultTemplate.StoreName,
IsFirstStore = !(stores.Any() || skipWizard),
DefaultCurrency = defaultCurrency,
Exchanges = GetExchangesSelectList(defaultCurrency, null)
DefaultCurrency = blob.DefaultCurrency,
Exchanges = GetExchangesSelectList(blob.DefaultCurrency, null),
CanEditPreferredExchange = blob.GetRateSettings(false)?.RateScripting is not true,
PreferredExchange = blob.GetRateSettings(false)?.PreferredExchange
};
return View(vm);
@@ -90,12 +91,14 @@ namespace BTCPayServer.Controllers
{
var stores = await _repo.GetStoresByUserId(GetUserId());
vm.IsFirstStore = !stores.Any();
var defaultCurrency = (await _settingsRepository.GetSettingAsync<PoliciesSettings>())?.DefaultCurrency ?? StoreBlob.StandardDefaultCurrency;
var template = await _repo.GetDefaultStoreTemplate();
var defaultCurrency = template.GetStoreBlob().DefaultCurrency ?? StoreBlob.StandardDefaultCurrency;
vm.Exchanges = GetExchangesSelectList(defaultCurrency, null);
return View(vm);
}
var store = new StoreData { StoreName = vm.Name };
var store = await _repo.GetDefaultStoreTemplate();
store.StoreName = vm.Name;
var blob = store.GetStoreBlob();
blob.DefaultCurrency = vm.DefaultCurrency;
blob.GetOrCreateRateSettings(false).PreferredExchange = vm.PreferredExchange;

View File

@@ -45,8 +45,6 @@ namespace BTCPayServer.Data
{
storeData.DefaultCrypto = defaultPaymentId?.ToString();
}
#pragma warning restore CS0618
public static StoreBlob GetStoreBlob(this StoreData storeData)
{
@@ -57,7 +55,7 @@ namespace BTCPayServer.Data
result.PaymentMethodCriteria.RemoveAll(criteria => criteria?.PaymentMethod is null);
return result;
}
#pragma warning restore CS0618
public static bool AnyPaymentMethodAvailable(this StoreData storeData, PaymentMethodHandlerDictionary handlers)
{
return storeData.GetPaymentMethodConfigs(handlers, true).Any();

View File

@@ -22,5 +22,6 @@ namespace BTCPayServer.Models.StoreViewModels
public string PreferredExchange { get; set; }
public SelectList Exchanges { get; set; }
public bool CanEditPreferredExchange { get; set; }
}
}

View File

@@ -11,18 +11,23 @@ namespace BTCPayServer.Models.StoreViewModels
public class Source
{
public bool ShowScripting { get; set; }
[Display(Name = "Rate Rules")]
[MaxLength(2000)]
public string Script { get; set; }
public string DefaultScript { get; set; }
[Display(Name = "Preferred Price Source")]
public string PreferredExchange { get; set; }
public SelectList Exchanges { get; set; }
public string RateSource { get; set; }
public string PreferredResolvedExchange { get; set; }
public bool IsFallback { get; set; }
public ConfirmModel ScriptingConfirm { get; set; }
}
public class TestResultViewModel
{
public string CurrencyPair { get; set; }
@@ -35,8 +40,9 @@ namespace BTCPayServer.Models.StoreViewModels
public Source PrimarySource { get; set; }
public Source FallbackSource { get; set; }
[Display(Name = "Enable fallback rates")]
public bool HasFallback {get; set; }
public bool HasFallback { get; set; }
public string ScriptTest { get; set; }
public string DefaultCurrencyPairs { get; set; }
@@ -45,6 +51,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Add Exchange Rate Spread")]
[Range(0.0, 100.0)]
public double Spread { get; set; }
public IEnumerable<RateSourceInfo> AvailableExchanges { get; set; }
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Data;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Validation;
@@ -82,8 +83,6 @@ namespace BTCPayServer.Services
[Display(Name = "Show plugins in pre-release")]
public bool PluginPreReleases { get; set; }
[Display(Name = "Select the Default Currency during Store Creation")]
public string DefaultCurrency { get; set; }
public bool DisableSSHService { get; set; }
@@ -101,6 +100,9 @@ namespace BTCPayServer.Services
[Display(Name = "Default role for users on a new store")]
public string DefaultRole { get; set; }
[Display(Name = "Default store template")]
public JObject DefaultStoreTemplate { get; set; }
public class BlockExplorerOverrideItem
{
[JsonConverter(typeof(PaymentMethodIdJsonConverter))]

View File

@@ -5,14 +5,21 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Migrations;
using BTCPayServer.Payments;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
using StoreWebhookData = BTCPayServer.Data.StoreWebhookData;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.Services.Stores
{
@@ -695,6 +702,67 @@ retry:
LIMIT 1;
""", new { storeId })) is true;
}
public async Task<StoreData> GetDefaultStoreTemplate()
{
var data = new StoreData();
var policies = await this._settingsRepository.GetSettingAsync<PoliciesSettings>();
if (policies?.DefaultStoreTemplate is null)
return data;
var serializer = new NBXplorer.Serializer(null);
serializer.Settings.DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate;
var r = serializer.ToObject<RestrictedStoreData>(policies.DefaultStoreTemplate);
if (!string.IsNullOrWhiteSpace(r.StoreName))
data.StoreName = r.StoreName;
if (r.SpeedPolicy is not null)
data.SpeedPolicy = r.SpeedPolicy.Value;
if (!string.IsNullOrWhiteSpace(r.StoreWebsite))
data.StoreWebsite = r.StoreWebsite;
if (!string.IsNullOrWhiteSpace(r.DefaultPaymentMethodId) && PaymentMethodId.TryParse(r.DefaultPaymentMethodId, out var paymentMethodId))
data.SetDefaultPaymentId(paymentMethodId);
if (r?.Blob is not null)
data.SetStoreBlob(r.Blob);
return data;
}
public async Task SetDefaultStoreTemplate(string storeId, string userId)
{
var storeData = await this.FindStore(storeId, userId);
if (storeData is null)
throw new InvalidOperationException("Store not found, or incorrect permissions");
await SetDefaultStoreTemplate(storeData);
}
public async Task SetDefaultStoreTemplate(StoreData? store)
{
var policies = await this._settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
if (store is null)
{
policies.DefaultStoreTemplate = null;
await _settingsRepository.UpdateSetting(policies);
return;
}
var serializer = new NBXplorer.Serializer(null);
serializer.Settings.DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate;
var r = new RestrictedStoreData()
{
StoreName = store.StoreName,
SpeedPolicy = store.SpeedPolicy,
StoreWebsite = store.StoreWebsite,
DefaultPaymentMethodId = store.GetDefaultPaymentId()?.ToString(),
Blob = store.GetStoreBlob()
};
policies.DefaultStoreTemplate = JObject.Parse(serializer.ToString(r));
await _settingsRepository.UpdateSetting(policies);
}
class RestrictedStoreData
{
public string? StoreName { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public SpeedPolicy? SpeedPolicy { get; set; }
public string? StoreWebsite { get; set; }
public string? DefaultPaymentMethodId { get; set; }
public StoreBlob? Blob { get; set; }
}
}
public record StoreRoleId

View File

@@ -202,8 +202,23 @@
<h4 class="mt-5" text-translate="true">Customization</h4>
<div class="form-group mb-3">
<label asp-for="DefaultCurrency" class="form-label"></label>
<input asp-for="DefaultCurrency" placeholder="@StoreBlob.StandardDefaultCurrency" class="form-control" currency-selection />
<label asp-for="DefaultStoreTemplate" class="form-label"></label>
@if (Model.DefaultStoreTemplate is null)
{
<div class="input-group">
<input value="@StringLocalizer["Not configured"]" type="text" readonly class="form-control"/>
<button type="submit" class="btn btn-primary" name="command" value="SetTemplate" id="SetTemplate" text-translate="true">Create template from selected store</button>
</div>
}
else
{
<div class="input-group">
<input value="@StringLocalizer["Configured"]" type="text" readonly class="form-control"/>
<button type="submit" class="btn btn-primary" name="command" value="SetTemplate" id="SetTemplate" text-translate="true">Replace template from selected store</button>
<button type="submit" class="btn btn-danger" name="command" value="ResetTemplate" id="ResetTemplate" text-translate="true">Remove store template</button>
</div>
}
<div class="form-text" text-translate="true">The store template sets defaults for all new stores. It includes rates settings, default invoice settings, checkout display settings but excludes sensitive data like access tokens, payment method settings, webhooks.</div>
</div>
<div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label>

View File

@@ -1,6 +1,7 @@
@model BTCPayServer.Models.StoreViewModels.CreateStoreViewModel
<form asp-action="CreateStore">
<input type="hidden" asp-for="CanEditPreferredExchange"/>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly"></div>
@@ -15,12 +16,15 @@
<input asp-for="DefaultCurrency" class="form-control w-300px" currency-selection />
<span asp-validation-for="DefaultCurrency" class="text-danger"></span>
</div>
@if (Model.CanEditPreferredExchange)
{
<div class="form-group">
<label asp-for="PreferredExchange" class="form-label" data-required></label>
<select asp-for="PreferredExchange" asp-items="Model.Exchanges" class="form-select w-300px"></select>
<div class="form-text mt-2 only-for-js" text-translate="true">The recommended price source gets chosen based on the default currency.</div>
<span asp-validation-for="PreferredExchange" class="text-danger"></span>
</div>
}
<div class="form-group mt-4">
<input type="submit" value="Create Store" class="btn btn-primary @(Model.IsFirstStore ? "w-100" : null)" id="Create" />
</div>

View File

@@ -45,7 +45,7 @@
"Stores"
],
"summary": "Create a new store",
"description": "Create a new store",
"description": "Create a new store (default values can be different if the server settings use a default store template)",
"requestBody": {
"x-name": "request",
"content": {
@@ -143,7 +143,7 @@
"$ref": "#/components/parameters/StoreId"
}
],
"description": "Update the specified store",
"description": "Update the specified store (default values can be different if the server settings use a default store template)",
"requestBody": {
"x-name": "request",
"content": {
@@ -541,7 +541,7 @@
]
},
"monitoringExpiration": {
"default": 3600,
"default": 86400,
"minimum": 600,
"maximum": 2073600,
"description": "The time after which an invoice which has been paid but not confirmed will be considered invalid. The value will be rounded down to a minute.",