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 Website { get; set; }
public string BrandColor { get; set; } public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; } public bool? ApplyBrandColorToBackend { get; set; }
public string LogoUrl { get; set; } public string LogoUrl { get; set; }
public string CssUrl { get; set; } public string CssUrl { get; set; }
public string PaymentSoundUrl { get; set; } public string PaymentSoundUrl { get; set; }
@@ -26,56 +26,56 @@ namespace BTCPayServer.Client.Models
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15); public TimeSpan? InvoiceExpiration { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan DisplayExpirationTimer { get; set; } = TimeSpan.FromMinutes(5); public TimeSpan? DisplayExpirationTimer { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Days))] [JsonConverter(typeof(TimeSpanJsonConverter.Days))]
[JsonProperty("refundBOLT11Expiration", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("refundBOLT11Expiration", NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan RefundBOLT11Expiration { get; set; } = TimeSpan.FromDays(30); public TimeSpan? RefundBOLT11Expiration { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60); public TimeSpan? MonitoringExpiration { get; set; }
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public SpeedPolicy SpeedPolicy { get; set; } public SpeedPolicy? SpeedPolicy { get; set; }
public string LightningDescriptionTemplate { get; set; } public string LightningDescriptionTemplate { get; set; }
public double PaymentTolerance { get; set; } = 0; public double? PaymentTolerance { get; set; }
public bool AnyoneCanCreateInvoice { get; set; } public bool? AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { get; set; } public string DefaultCurrency { get; set; }
public bool LightningAmountInSatoshi { get; set; } public bool? LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; } public bool? LightningPrivateRouteHints { get; set; }
public bool OnChainWithLnInvoiceFallback { get; set; } public bool? OnChainWithLnInvoiceFallback { get; set; }
public bool LazyPaymentMethods { get; set; } public bool? LazyPaymentMethods { get; set; }
public bool RedirectAutomatically { get; set; } public bool? RedirectAutomatically { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool Archived { get; set; } public bool? Archived { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool ShowRecommendedFee { get; set; } = true; public bool? ShowRecommendedFee { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int RecommendedFeeBlockTarget { get; set; } = 1; public int? RecommendedFeeBlockTarget { get; set; }
public string DefaultPaymentMethod { get; set; } public string DefaultPaymentMethod { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string DefaultLang { get; set; } = "en"; public string DefaultLang { get; set; }
public string HtmlTitle { get; set; } public string HtmlTitle { get; set; }
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public NetworkFeeMode NetworkFeeMode { get; set; } = NetworkFeeMode.Never; public NetworkFeeMode? NetworkFeeMode { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public List<PaymentMethodCriteriaData> PaymentMethodCriteria { get; set; } public List<PaymentMethodCriteriaData> PaymentMethodCriteria { get; set; }
public bool PayJoinEnabled { get; set; } public bool? PayJoinEnabled { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool? AutoDetectLanguage { get; set; } 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, Coingecko,
Direct 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -15,7 +14,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Playwright; using Microsoft.Playwright;
using NBitcoin; using NBitcoin;
using NBitcoin.RPC; using NBitcoin.RPC;
using OpenQA.Selenium;
using Xunit; using Xunit;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
@@ -42,8 +40,6 @@ namespace BTCPayServer.Tests
await Server.StartAsync(); await Server.StartAsync();
var builder = new ConfigurationBuilder(); var builder = new ConfigurationBuilder();
builder.AddUserSecrets("AB0AC1DD-9D26-485B-9416-56A33F268117"); builder.AddUserSecrets("AB0AC1DD-9D26-485B-9416-56A33F268117");
var config = builder.Build();
var playwright = await Playwright.CreateAsync(); var playwright = await Playwright.CreateAsync();
Browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions Browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{ {
@@ -93,7 +89,7 @@ namespace BTCPayServer.Tests
} }
else else
{ {
await GoToUrl(storeId == null ? "/invoices/" : $"/stores/{storeId}/invoices/"); await GoToUrl($"/stores/{storeId}/invoices/");
StoreId = storeId; StoreId = storeId;
} }
} }
@@ -123,9 +119,9 @@ namespace BTCPayServer.Tests
await Page.SelectOptionAsync("select[name='DefaultPaymentMethod']", new SelectOptionValue { Value = defaultPaymentMethod }); await Page.SelectOptionAsync("select[name='DefaultPaymentMethod']", new SelectOptionValue { Value = defaultPaymentMethod });
await ClickPagePrimary(); await ClickPagePrimary();
var statusText = (await FindAlertMessage(expectedSeverity)).TextContentAsync(); var statusText = await (await FindAlertMessage(expectedSeverity)).TextContentAsync();
var inv = expectedSeverity == StatusMessageModel.StatusSeverity.Success 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; : null;
InvoiceId = inv; 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 }; 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 (await Page.Locator("#StoreSelectorToggle").IsVisibleAsync()) if (!Page.Url.EndsWith("stores/create"))
{ {
await Page.Locator("#StoreSelectorToggle").ClickAsync(); if (await Page.Locator("#StoreSelectorToggle").IsVisibleAsync())
{
await Page.ClickAsync("#StoreSelectorToggle");
await Page.ClickAsync("#StoreSelectorCreate");
}
else
{
await GoToUrl("/stores/create");
}
} }
await GoToUrl("/stores/create");
var name = "Store" + RandomUtils.GetUInt64(); var name = "Store" + RandomUtils.GetUInt64();
TestLogs.LogInformation($"Created store {name}"); TestLogs.LogInformation($"Created store {name}");
await Page.FillAsync("#Name", name); await Page.FillAsync("#Name", name);
var selectedOption = await Page.Locator("#PreferredExchange option:checked").TextContentAsync(); var selectedOption = await Page.Locator("#PreferredExchange option:checked").TextContentAsync();
Assert.Equal("Recommendation (Kraken)", selectedOption.Trim()); Assert.Equal("Recommendation (Kraken)", selectedOption?.Trim());
await Page.Locator("#PreferredExchange").SelectOptionAsync(new SelectOptionValue { Label = "CoinGecko" }); await Page.Locator("#PreferredExchange").SelectOptionAsync(new SelectOptionValue { Label = preferredExchange });
await Page.ClickAsync("#Create"); await Page.ClickAsync("#Create");
await Page.ClickAsync("#StoreNav-General"); await Page.ClickAsync("#StoreNav-General");
var storeId = await Page.InputValueAsync("#Id"); var storeId = await Page.InputValueAsync("#Id");
@@ -471,7 +475,7 @@ namespace BTCPayServer.Tests
walletId ??= WalletId; walletId ??= WalletId;
await GoToWallet(walletId, WalletsNavPages.Receive); await GoToWallet(walletId, WalletsNavPages.Receive);
var addressStr = await Page.Locator("#Address").GetAttributeAsync("data-text"); 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++) for (var i = 0; i < coins; i++)
{ {
bool mined = false; bool mined = false;

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@@ -12,6 +13,7 @@ using BTCPayServer.Views.Stores;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Playwright; using Microsoft.Playwright;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json.Linq;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -38,7 +40,6 @@ namespace BTCPayServer.Tests
Assert.Contains("Starting listening NBXplorer", await s.Page.ContentAsync()); Assert.Contains("Starting listening NBXplorer", await s.Page.ContentAsync());
} }
[Fact] [Fact]
public async Task CanUseForms() public async Task CanUseForms()
{ {
@@ -489,5 +490,92 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Configured", await s.Page.ContentAsync()); Assert.DoesNotContain("Configured", await s.Page.ContentAsync());
Assert.Contains("test_fix", 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 script = await tester.Page.InputValueAsync($"#{source}_Script");
var defaultScript = await tester.Page.GetAttributeAsync($"#{source}_DefaultScript", "data-defaultScript"); 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"); await Test("BTC_JPY");
rules = await tester.Page.Locator(".testresult .testresult_rule").AllAsync(); rules = await tester.Page.Locator(".testresult .testresult_rule").AllAsync();

View File

@@ -1,8 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.Runtime.Internal;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
@@ -19,7 +19,9 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; 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; using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield namespace BTCPayServer.Controllers.Greenfield
@@ -34,12 +36,14 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IFileService _fileService; private readonly IFileService _fileService;
private readonly UriResolver _uriResolver; private readonly UriResolver _uriResolver;
private readonly JsonSerializerSettings _serializedSettings;
public GreenfieldStoresController( public GreenfieldStoresController(
StoreRepository storeRepository, StoreRepository storeRepository,
CurrencyNameTable currencyNameTable, CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IFileService fileService, IFileService fileService,
IOptions<MvcNewtonsoftJsonOptions> jsonOptions,
UriResolver uriResolver) UriResolver uriResolver)
{ {
_storeRepository = storeRepository; _storeRepository = storeRepository;
@@ -47,6 +51,7 @@ namespace BTCPayServer.Controllers.Greenfield
_userManager = userManager; _userManager = userManager;
_fileService = fileService; _fileService = fileService;
_uriResolver = uriResolver; _uriResolver = uriResolver;
_serializedSettings = jsonOptions.Value.SerializerSettings;
} }
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -77,7 +82,7 @@ namespace BTCPayServer.Controllers.Greenfield
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound(); if (store == null) return StoreNotFound();
await _storeRepository.RemoveStore(storeId, _userManager.GetUserId(User)); await _storeRepository.RemoveStore(storeId, _userManager.GetUserId(User) ?? "");
return Ok(); return Ok();
} }
@@ -85,27 +90,39 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettingsUnscoped, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettingsUnscoped, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateStore(CreateStoreRequest request) public async Task<IActionResult> CreateStore(CreateStoreRequest request)
{ {
var store = await _storeRepository.GetDefaultStoreTemplate();
request = await MergeStoreRequestWithTemplate(request, store);
var validationResult = Validate(request); var validationResult = Validate(request);
if (validationResult != null) return validationResult; if (validationResult != null) return validationResult;
ToModel(request, store);
var store = new StoreData(); await _storeRepository.CreateStore(_userManager.GetUserId(User) ?? "", store);
PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId);
ToModel(request, store, defaultPaymentMethodId);
await _storeRepository.CreateStore(_userManager.GetUserId(User), store);
return Ok(await FromModel(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)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}")] [HttpPut("~/api/v1/stores/{storeId}")]
public async Task<IActionResult> UpdateStore(string storeId, UpdateStoreRequest request) public async Task<IActionResult> UpdateStore(string storeId, UpdateStoreRequest request)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound(); if (store == null) return StoreNotFound();
request = await MergeStoreRequestWithTemplate(request, store);
var validationResult = Validate(request); var validationResult = Validate(request);
if (validationResult != null) return validationResult; if (validationResult != null) return validationResult;
ToModel(request, store);
PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId);
ToModel(request, store, defaultPaymentMethodId);
await _storeRepository.UpdateStore(store); await _storeRepository.UpdateStore(store);
return Ok(await FromModel(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(); var blob = model.GetStoreBlob();
model.StoreName = restModel.Name; model.StoreName = restModel.Name;
model.StoreWebsite = restModel.Website; model.StoreWebsite = restModel.Website;
model.Archived = restModel.Archived; model.Archived = restModel.Archived.Value;
model.SpeedPolicy = restModel.SpeedPolicy; model.SpeedPolicy = restModel.SpeedPolicy.Value;
PaymentMethodId.TryParse(restModel.DefaultPaymentMethod, out var defaultPaymentMethod);
model.SetDefaultPaymentId(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 //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 //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 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 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) //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.DefaultCurrency = restModel.DefaultCurrency;
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null); blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi; blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi.Value;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints; blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints.Value;
blob.OnChainWithLnInvoiceFallback = restModel.OnChainWithLnInvoiceFallback; blob.OnChainWithLnInvoiceFallback = restModel.OnChainWithLnInvoiceFallback.Value;
blob.LazyPaymentMethods = restModel.LazyPaymentMethods; blob.LazyPaymentMethods = restModel.LazyPaymentMethods.Value;
blob.RedirectAutomatically = restModel.RedirectAutomatically; blob.RedirectAutomatically = restModel.RedirectAutomatically.Value;
blob.ShowRecommendedFee = restModel.ShowRecommendedFee; blob.ShowRecommendedFee = restModel.ShowRecommendedFee.Value;
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget; blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget.Value;
blob.DefaultLang = restModel.DefaultLang; blob.DefaultLang = restModel.DefaultLang;
blob.StoreSupportUrl = restModel.SupportUrl; blob.StoreSupportUrl = restModel.SupportUrl;
blob.MonitoringExpiration = restModel.MonitoringExpiration; blob.MonitoringExpiration = restModel.MonitoringExpiration.Value;
blob.InvoiceExpiration = restModel.InvoiceExpiration; blob.InvoiceExpiration = restModel.InvoiceExpiration.Value;
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer; blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer.Value;
blob.HtmlTitle = restModel.HtmlTitle; blob.HtmlTitle = restModel.HtmlTitle;
blob.AnyoneCanInvoice = restModel.AnyoneCanCreateInvoice; blob.AnyoneCanInvoice = restModel.AnyoneCanCreateInvoice.Value;
blob.LightningDescriptionTemplate = restModel.LightningDescriptionTemplate; blob.LightningDescriptionTemplate = restModel.LightningDescriptionTemplate;
blob.PaymentTolerance = restModel.PaymentTolerance; blob.PaymentTolerance = restModel.PaymentTolerance.Value;
blob.PayJoinEnabled = restModel.PayJoinEnabled; blob.PayJoinEnabled = restModel.PayJoinEnabled.Value;
blob.BrandColor = restModel.BrandColor; 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.LogoUrl = restModel.LogoUrl is null ? null : UnresolvedUri.Create(restModel.LogoUrl);
blob.CssUrl = restModel.CssUrl is null ? null : UnresolvedUri.Create(restModel.CssUrl); 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); blob.PaymentSoundUrl = restModel.PaymentSoundUrl is null ? null : UnresolvedUri.Create(restModel.PaymentSoundUrl);
if (restModel.AutoDetectLanguage.HasValue) if (restModel.AutoDetectLanguage.HasValue)
blob.AutoDetectLanguage = restModel.AutoDetectLanguage.Value; blob.AutoDetectLanguage = restModel.AutoDetectLanguage.Value;
@@ -336,24 +356,25 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
for (int index = 0; index < request.PaymentMethodCriteria.Count; index++) for (int index = 0; index < request.PaymentMethodCriteria.Count; index++)
{ {
var i = index;
PaymentMethodCriteriaData pmc = request.PaymentMethodCriteria[index]; PaymentMethodCriteriaData pmc = request.PaymentMethodCriteria[index];
if (string.IsNullOrEmpty(pmc.CurrencyCode)) 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) 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) 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) 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(); 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") if (command == "add-domain")
{ {
ModelState.Clear(); ModelState.Clear();

View File

@@ -72,8 +72,18 @@ public partial class UIStoresController
{ {
var isFallback = command is "scripting-toggle-fallback"; var isFallback = command is "scripting-toggle-fallback";
var rateSettings = storeBlob.GetOrCreateRateSettings(isFallback); var rateSettings = storeBlob.GetOrCreateRateSettings(isFallback);
rateSettings.RateScripting = !rateSettings.RateScripting;
rateSettings.RateScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString(); if (!rateSettings.RateScripting)
{
rateSettings.RateScript = rateSettings.GetDefaultRateRules(_defaultRules, storeBlob.Spread).ToString();
rateSettings.RateScripting = true;
}
else
{
rateSettings.RateScripting = false;
rateSettings.RateScript = null;
}
CurrentStore.SetStoreBlob(storeBlob); CurrentStore.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(CurrentStore); await _storeRepo.UpdateStore(CurrentStore);
if (rateSettings.RateScripting) 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) if (rateSettings is null)
return; return;
@@ -191,7 +201,9 @@ public partial class UIStoresController
vm.PreferredResolvedExchange = chosenSource.Id; vm.PreferredResolvedExchange = chosenSource.Id;
vm.RateSource = chosenSource.Url; vm.RateSource = chosenSource.Url;
vm.Script = rateSettings.GetRateRules(_defaultRules, storeBlob.Spread).ToString(); 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.ShowScripting = rateSettings.RateScripting;
vm.ScriptingConfirm = new() vm.ScriptingConfirm = new()

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,21 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Migrations; using BTCPayServer.Migrations;
using BTCPayServer.Payments;
using Dapper; using Dapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using Newtonsoft.Json; 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 namespace BTCPayServer.Services.Stores
{ {
@@ -695,6 +702,67 @@ retry:
LIMIT 1; LIMIT 1;
""", new { storeId })) is true; """, 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 public record StoreRoleId

View File

@@ -202,8 +202,23 @@
<h4 class="mt-5" text-translate="true">Customization</h4> <h4 class="mt-5" text-translate="true">Customization</h4>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label asp-for="DefaultCurrency" class="form-label"></label> <label asp-for="DefaultStoreTemplate" class="form-label"></label>
<input asp-for="DefaultCurrency" placeholder="@StoreBlob.StandardDefaultCurrency" class="form-control" currency-selection /> @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>
<div class="form-group mb-5"> <div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label> <label asp-for="RootAppId" class="form-label"></label>

View File

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

View File

@@ -45,7 +45,7 @@
"Stores" "Stores"
], ],
"summary": "Create a new store", "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": { "requestBody": {
"x-name": "request", "x-name": "request",
"content": { "content": {
@@ -143,7 +143,7 @@
"$ref": "#/components/parameters/StoreId" "$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": { "requestBody": {
"x-name": "request", "x-name": "request",
"content": { "content": {
@@ -541,7 +541,7 @@
] ]
}, },
"monitoringExpiration": { "monitoringExpiration": {
"default": 3600, "default": 86400,
"minimum": 600, "minimum": 600,
"maximum": 2073600, "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.", "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.",