diff --git a/BTCPayServer.Client/BTCPayServerClient.Apps.cs b/BTCPayServer.Client/BTCPayServerClient.Apps.cs index 9a435b3b4..2e605a6c3 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Apps.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Apps.cs @@ -19,6 +19,17 @@ namespace BTCPayServer.Client method: HttpMethod.Post), token); return await HandleResponse(response); } + + public virtual async Task CreateCrowdfundApp(string storeId, + CreateCrowdfundAppRequest request, CancellationToken token = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/apps/crowdfund", bodyPayload: request, + method: HttpMethod.Post), token); + return await HandleResponse(response); + } public virtual async Task UpdatePointOfSaleApp(string appId, CreatePointOfSaleAppRequest request, CancellationToken token = default) diff --git a/BTCPayServer.Client/Models/CreateAppRequest.cs b/BTCPayServer.Client/Models/CreateAppRequest.cs index 4cd9d22d6..5c92feb19 100644 --- a/BTCPayServer.Client/Models/CreateAppRequest.cs +++ b/BTCPayServer.Client/Models/CreateAppRequest.cs @@ -1,3 +1,4 @@ +using System; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -40,4 +41,44 @@ namespace BTCPayServer.Client.Models public string EmbeddedCSS { get; set; } = null; public CheckoutType? CheckoutType { get; set; } = null; } + + public enum CrowdfundResetEvery + { + Never, + Hour, + Day, + Month, + Year + } + + public class CreateCrowdfundAppRequest : CreateAppRequest + { + public string Title { get; set; } = null; + public bool? Enabled { get; set; } = null; + public bool? EnforceTargetAmount { get; set; } = null; + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? StartDate { get; set; } = null; + public string TargetCurrency { get; set; } = null; + public string Description { get; set; } = null; + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? EndDate { get; set; } = null; + public decimal? TargetAmount { get; set; } = null; + public string CustomCSSLink { get; set; } = null; + public string MainImageUrl { get; set; } = null; + public string EmbeddedCSS { get; set; } = null; + public string NotificationUrl { get; set; } = null; + public string Tagline { get; set; } = null; + public string PerksTemplate { get; set; } = null; + public bool? SoundsEnabled { get; set; } = null; + public string DisqusShortname { get; set; } = null; + public bool? AnimationsEnabled { get; set; } = null; + public int? ResetEveryAmount { get; set; } = null; + [JsonConverter(typeof(StringEnumConverter))] + public CrowdfundResetEvery ResetEvery { get; set; } = CrowdfundResetEvery.Never; + public bool? DisplayPerksValue { get; set; } = null; + public bool? DisplayPerksRanking { get; set; } = null; + public bool? SortPerksByPopularity { get; set; } = null; + public string[] Sounds { get; set; } = null; + public string[] AnimationColors { get; set; } = null; + } } diff --git a/BTCPayServer.Client/Models/PointOfSaleAppData.cs b/BTCPayServer.Client/Models/PointOfSaleAppData.cs index 355672744..a8bd30d5a 100644 --- a/BTCPayServer.Client/Models/PointOfSaleAppData.cs +++ b/BTCPayServer.Client/Models/PointOfSaleAppData.cs @@ -17,4 +17,9 @@ namespace BTCPayServer.Client.Models { // We can add POS specific things here later } + + public class CrowdfundAppData : AppDataBase + { + // We can add Crowdfund specific things here later + } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index c8df6fe1f..1300372c3 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -288,6 +288,117 @@ namespace BTCPayServer.Tests await client.GetApp(retrievedApp.Id); }); } + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanCreateCrowdfundApp() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.RegisterDerivationSchemeAsync("BTC"); + var client = await user.CreateClient(); + + // Test validation for creating the app + await AssertValidationError(new[] { "AppName" }, + async () => await client.CreateCrowdfundApp(user.StoreId, new CreateCrowdfundAppRequest() {})); + await AssertValidationError(new[] { "AppName" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "this is a really long app name this is a really long app name this is a really long app name", + } + ) + ); + await AssertValidationError(new[] { "TargetCurrency" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + TargetCurrency = "fake currency" + } + ) + ); + await AssertValidationError(new[] { "PerksTemplate" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + PerksTemplate = "lol invalid template" + } + ) + ); + await AssertValidationError(new[] { "AppName", "TargetCurrency", "PerksTemplate" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + TargetCurrency = "fake currency", + PerksTemplate = "lol invalid template" + } + ) + ); + await AssertValidationError(new[] { "AnimationColors" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + AnimationColors = new string[] {} + } + ) + ); + await AssertValidationError(new[] { "AnimationColors" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + AnimationColors = new string[] { " ", " " } + } + ) + ); + await AssertValidationError(new[] { "Sounds" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + Sounds = new string[] { " " } + } + ) + ); + await AssertValidationError(new[] { "Sounds" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + Sounds = new string[] { " ", " ", " " } + } + ) + ); + await AssertValidationError(new[] { "EndDate" }, + async () => await client.CreateCrowdfundApp( + user.StoreId, + new CreateCrowdfundAppRequest() + { + AppName = "good name", + StartDate = DateTime.Parse("1998-01-01"), + EndDate = DateTime.Parse("1997-12-31") + } + ) + ); + + // Test creating a crowdfund app + var app = await client.CreateCrowdfundApp(user.StoreId, new CreateCrowdfundAppRequest() { AppName = "test app from API" }); + Assert.Equal("test app from API", app.Name); + Assert.Equal(user.StoreId, app.StoreId); + Assert.Equal("Crowdfund", app.AppType); + } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs index 214428a33..ce65189da 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Client; @@ -38,6 +39,37 @@ namespace BTCPayServer.Controllers.Greenfield _currencies = currencies; } + [HttpPost("~/api/v1/stores/{storeId}/apps/crowdfund")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task CreateCrowdfundApp(string storeId, CreateCrowdfundAppRequest request) + { + var store = await _storeRepository.FindStore(storeId); + if (store == null) + return this.CreateAPIError(404, "store-not-found", "The store was not found"); + + // This is not obvious but we must have a non-null currency or else request validation may work incorrectly + request.TargetCurrency = request.TargetCurrency ?? store.GetStoreBlob().DefaultCurrency; + + var validationResult = ValidateCrowdfundAppRequest(request); + if (validationResult != null) + { + return validationResult; + } + + var appData = new AppData + { + StoreDataId = storeId, + Name = request.AppName, + AppType = AppType.Crowdfund.ToString() + }; + + appData.SetSettings(ToCrowdfundSettings(request)); + + await _appService.UpdateOrCreateApp(appData); + + return Ok(ToCrowdfundModel(appData)); + } + [HttpPost("~/api/v1/stores/{storeId}/apps/pos")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task CreatePointOfSaleApp(string storeId, CreatePointOfSaleAppRequest request) @@ -66,7 +98,7 @@ namespace BTCPayServer.Controllers.Greenfield await _appService.UpdateOrCreateApp(appData); - return Ok(ToModel(appData)); + return Ok(ToPointOfSaleModel(appData)); } [HttpPut("~/api/v1/apps/pos/{appId}")] @@ -95,7 +127,7 @@ namespace BTCPayServer.Controllers.Greenfield await _appService.UpdateOrCreateApp(app); - return Ok(ToModel(app)); + return Ok(ToPointOfSaleModel(app)); } private RequiresRefundEmail? BoolToRequiresRefundEmail(bool? requiresRefundEmail) @@ -115,7 +147,7 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task GetApp(string appId) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, null); if (app == null) { return AppNotFound(); @@ -143,6 +175,43 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found"); } + private CrowdfundSettings ToCrowdfundSettings(CreateCrowdfundAppRequest request) + { + var parsedSounds = ValidateStringArray(request.Sounds); + var parsedColors = ValidateStringArray(request.AnimationColors); + + return new CrowdfundSettings + { + Title = request.Title?.Trim(), + Enabled = request.Enabled ?? true, + EnforceTargetAmount = request.EnforceTargetAmount ?? false, + StartDate = request.StartDate?.UtcDateTime, + TargetCurrency = request.TargetCurrency?.Trim(), + Description = request.Description?.Trim(), + EndDate = request.EndDate?.UtcDateTime, + TargetAmount = request.TargetAmount, + CustomCSSLink = request.CustomCSSLink?.Trim(), + MainImageUrl = request.MainImageUrl?.Trim(), + EmbeddedCSS = request.EmbeddedCSS?.Trim(), + NotificationUrl = request.NotificationUrl?.Trim(), + Tagline = request.Tagline?.Trim(), + PerksTemplate = request.PerksTemplate != null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate?.Trim(), request.TargetCurrency)) : null, + // If Disqus shortname is not null or empty we assume that Disqus should be enabled + DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()), + DisqusShortname = request.DisqusShortname?.Trim(), + // If explicit parameter is not passed for enabling sounds/animations, turn them on if custom sounds/colors are passed + SoundsEnabled = request.SoundsEnabled ?? parsedSounds != null, + AnimationsEnabled = request.AnimationsEnabled ?? parsedColors != null, + ResetEveryAmount = request.ResetEveryAmount ?? 1, + ResetEvery = (Services.Apps.CrowdfundResetEvery)request.ResetEvery, + DisplayPerksValue = request.DisplayPerksValue ?? false, + DisplayPerksRanking = request.DisplayPerksRanking ?? false, + SortPerksByPopularity = request.SortPerksByPopularity ?? false, + Sounds = parsedSounds ?? new CrowdfundSettings().Sounds, + AnimationColors = parsedColors ?? new CrowdfundSettings().AnimationColors + }; + } + private PointOfSaleSettings ToPointOfSaleSettings(CreatePointOfSaleAppRequest request) { return new PointOfSaleSettings() @@ -169,10 +238,20 @@ namespace BTCPayServer.Controllers.Greenfield }; } - private PointOfSaleAppData ToModel(AppData appData) + private AppDataBase ToModel(AppData appData) { - var settings = appData.GetSettings(); + return new AppDataBase + { + Id = appData.Id, + AppType = appData.AppType, + Name = appData.Name, + StoreId = appData.StoreDataId, + Created = appData.Created, + }; + } + private PointOfSaleAppData ToPointOfSaleModel(AppData appData) + { return new PointOfSaleAppData { Id = appData.Id, @@ -211,6 +290,84 @@ namespace BTCPayServer.Controllers.Greenfield return validationResult; } + private CrowdfundAppData ToCrowdfundModel(AppData appData) + { + return new CrowdfundAppData + { + Id = appData.Id, + AppType = appData.AppType, + Name = appData.Name, + StoreId = appData.StoreDataId, + Created = appData.Created + }; + } + + private string[]? ValidateStringArray(string[]? arr) + { + if (arr == null || !arr.Any()) + { + return null; + } + + // Make sure it's not just an array of empty strings + if (arr.All(s => string.IsNullOrEmpty(s.Trim()))) + { + return null; + } + + return arr.Select(s => s.Trim()).ToArray(); + } + + private IActionResult? ValidateCrowdfundAppRequest(CreateCrowdfundAppRequest request) + { + var validationResult = ValidateCreateAppRequest(request); + if (request.TargetCurrency != null && _currencies.GetCurrencyData(request.TargetCurrency, false) == null) + { + ModelState.AddModelError(nameof(request.TargetCurrency), "Invalid currency"); + } + + try + { + _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, request.TargetCurrency)); + } + catch + { + ModelState.AddModelError(nameof(request.PerksTemplate), "Invalid template"); + } + + if (request.ResetEvery != Client.Models.CrowdfundResetEvery.Never && request.StartDate == null) + { + ModelState.AddModelError(nameof(request.StartDate), "A start date is needed when the goal resets every X amount of time"); + } + + if (request.ResetEvery != Client.Models.CrowdfundResetEvery.Never && request.ResetEveryAmount <= 0) + { + ModelState.AddModelError(nameof(request.ResetEveryAmount), "You must reset the goal at a minimum of 1"); + } + + if (request.Sounds != null && ValidateStringArray(request.Sounds) == null) + { + ModelState.AddModelError(nameof(request.Sounds), "Sounds must be a non-empty array of non-empty strings"); + } + + if (request.AnimationColors != null && ValidateStringArray(request.AnimationColors) == null) + { + ModelState.AddModelError(nameof(request.AnimationColors), "Animation colors must be a non-empty array of non-empty strings"); + } + + if (request.StartDate != null && request.EndDate != null && DateTimeOffset.Compare((DateTimeOffset)request.StartDate, (DateTimeOffset)request.EndDate!) > 0) + { + ModelState.AddModelError(nameof(request.EndDate), "End date cannot be before start date"); + } + + if (!ModelState.IsValid) + { + validationResult = this.CreateValidationError(ModelState); + } + + return validationResult; + } + private IActionResult? ValidateCreateAppRequest(CreateAppRequest request) { if (request is null) diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index a5f2d0677..8a39b2cf8 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -1163,6 +1163,14 @@ namespace BTCPayServer.Controllers.Greenfield await GetController().UpdatePointOfSaleApp(appId, request)); } + public override async Task CreateCrowdfundApp( + string storeId, + CreateCrowdfundAppRequest request, CancellationToken token = default) + { + return GetFromActionResult( + await GetController().CreateCrowdfundApp(storeId, request)); + } + public override async Task GetApp(string appId, CancellationToken token = default) { return GetFromActionResult( diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json index c199b80b5..bb6774997 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json @@ -15,7 +15,7 @@ "post": { "operationId": "Apps_CreatePointOfSaleApp", "summary": "Create a new Point of Sale app", - "description": "Point of Sale apps allows accepting payments for items in a virtual store", + "description": "Point of Sale app allows accepting payments for items in a virtual store", "requestBody": { "x-name": "request", "content": { @@ -126,6 +126,69 @@ ] } }, + "/api/v1/stores/{storeId}/apps/crowdfund": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store ID", + "schema": { + "type": "string" + } + } + ], + "post": { + "operationId": "Apps_CreateCrowdfundApp", + "summary": "Create a new Crowdfund app", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCrowdfundAppRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Created app details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CrowdfundAppData" + } + } + } + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "tags": [ + "Apps", + "Crowdfund" + ], + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, "/api/v1/apps/{appId}": { "get": { "tags": [ @@ -217,6 +280,15 @@ } ] }, + "CrowdfundAppData": { + "allOf": [ + { + "$ref": "#/components/schemas/BasicAppData" + }, + { + } + ] + }, "BasicAppData": { "type": "object", "properties": { @@ -368,6 +440,171 @@ "nullable": true } } + }, + "CreateCrowdfundAppRequest": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "description": "The name of the app (shown in admin UI)", + "example": "Kukkstarter", + "nullable": false + }, + "title": { + "type": "string", + "description": "The title of the app (shown to the user)", + "example": "My crowdfund app", + "nullable": true + }, + "description": { + "type": "string", + "description": "The description of the app (shown to the user)", + "example": "My app description", + "nullable": true + }, + "enabled": { + "type": "boolean", + "description": "Determines if the app is enabled to be viewed by everyone", + "default": true, + "nullable": true + }, + "enforceTargetAmount": { + "type": "boolean", + "description": "Will not allow contributions over the set target amount", + "default": false, + "nullable": true + }, + "startDate": { + "type": "number", + "description": "UNIX timestamp for crowdfund start time (https://www.unixtimestamp.com/)", + "allOf": [ {"$ref": "#/components/schemas/UnixTimestamp"}], + "example": 768658369, + "nullable": true + }, + "endDate": { + "type": "number", + "description": "UNIX timestamp for crowdfund end time (https://www.unixtimestamp.com/)", + "allOf": [ {"$ref": "#/components/schemas/UnixTimestamp"}], + "example": 771336769, + "nullable": true + }, + "targetCurrency": { + "type": "string", + "description": "Target currency for the crowdfund. Defaults to the currency used by the store if not specified", + "example": "BTC", + "nullable": true + }, + "targetAmount": { + "type": "number", + "description": "Target amount for the crowdfund", + "example": 420, + "nullable": true + }, + "customCSSLink": { + "type": "string", + "description": "Link to a custom CSS stylesheet to be used in the app", + "nullable": true + }, + "mainImageUrl": { + "type": "string", + "description": "URL for image to be used as a cover image for the app", + "nullable": true + }, + "embeddedCSS": { + "type": "string", + "description": "Custom CSS to embed into the app", + "nullable": true + }, + "perksTemplate": { + "type": "string", + "description": "YAML template of perks available in the app", + "example": "test_perk:\r\n price: 100\r\n title: test perk\r\n price_type: \"fixed\" \r\n disabled: false", + "nullable": true + }, + "notificationUrl": { + "type": "string", + "description": "Callback notification url to POST to once when invoice is paid for and once when there are enough blockchain confirmations", + "nullable": true + }, + "tagline": { + "type": "string", + "description": "Tagline for the app (shown to the user)", + "example": "I can't believe it's not butter", + "nullable": true + }, + "disqusShortname": { + "type": "string", + "description": "Disqus shortname to used for the app. Enables Disqus functionality if set.", + "nullable": true + }, + "soundsEnabled": { + "type": "boolean", + "description": "Enables sounds on new contributions if set to true", + "default": false, + "nullable": true + }, + "animationsEnabled": { + "type": "boolean", + "description": "Enables background animations on new contributions if set to true", + "default": false, + "nullable": true + }, + "resetEveryAmount": { + "type": "number", + "description": "Contribution goal reset frequency amount. Must be used in conjunction with resetEvery and startDate.", + "default": 1, + "nullable": true + }, + "resetEvery": { + "type": "string", + "description": "Contribution goal reset frequency. Must be used in conjunction with resetEveryAmount and startDate.", + "nullable": true, + "default": "Never", + "x-enumNames": [ + "Never", + "Hour", + "Day", + "Month", + "Year" + ], + "enum": [ + "Never", + "Hour", + "Day", + "Month", + "Year" + ] + }, + "displayPerksValue": { + "type": "boolean", + "description": "Enables background animations on new contributions if set to true", + "default": false, + "nullable": true + }, + "sortPerksByPopularity": { + "type": "boolean", + "description": "Sorts perks by popularity if set to true", + "default": false, + "nullable": true + }, + "sounds": { + "type": "array", + "description": "Array of custom sounds to use on new contributions", + "items": { + "type": "string" + }, + "nullable": true + }, + "animationColors": { + "type": "array", + "description": "Array of custom HEX colors to use for background animations on new contributions", + "items": { + "type": "string" + }, + "nullable": true, + "example": ["#FF0000", "#00FF00", "#0000FF"] + } + } } } },