diff --git a/BTCPayServer.Client/BTCPayServerClient.Apps.cs b/BTCPayServer.Client/BTCPayServerClient.Apps.cs index d24006e6e..3006eac33 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Apps.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Apps.cs @@ -20,6 +20,17 @@ namespace BTCPayServer.Client return await HandleResponse(response); } + public virtual async Task PutPointOfSaleApp(string appId, + CreatePointOfSaleAppRequest request, CancellationToken token = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/apps/pos/{appId}", bodyPayload: request, + method: HttpMethod.Put), token); + return await HandleResponse(response); + } + public virtual async Task GetApp(string appId, CancellationToken token = default) { if (appId == null) diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 1d1b25cb9..d4d479f2e 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -195,7 +195,7 @@ namespace BTCPayServer.Tests [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] - public async Task CanCreateReadAndDeletePointOfSaleApp() + public async Task CanCreateReadUpdateAndDeletePointOfSaleApp() { using var tester = CreateServerTester(); await tester.StartAsync(); @@ -203,8 +203,58 @@ namespace BTCPayServer.Tests await user.RegisterDerivationSchemeAsync("BTC"); var client = await user.CreateClient(); - // Test creating a POS app - var app = await client.CreatePointOfSaleApp(user.StoreId, new CreatePointOfSaleAppRequest() { AppName = "test app from API" }); + // Test validation for creating the app + await AssertValidationError(new[] { "AppName" }, + async () => await client.CreatePointOfSaleApp(user.StoreId, new CreatePointOfSaleAppRequest() {})); + await AssertValidationError(new[] { "AppName" }, + async () => await client.CreatePointOfSaleApp( + user.StoreId, + new CreatePointOfSaleAppRequest() + { + 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[] { "Currency" }, + async () => await client.CreatePointOfSaleApp( + user.StoreId, + new CreatePointOfSaleAppRequest() + { + AppName = "good name", + Currency = "fake currency" + } + ) + ); + await AssertValidationError(new[] { "Template" }, + async () => await client.CreatePointOfSaleApp( + user.StoreId, + new CreatePointOfSaleAppRequest() + { + AppName = "good name", + Template = "lol invalid template" + } + ) + ); + await AssertValidationError(new[] { "AppName", "Currency", "Template" }, + async () => await client.CreatePointOfSaleApp( + user.StoreId, + new CreatePointOfSaleAppRequest() + { + Currency = "fake currency", + Template = "lol invalid template" + } + ) + ); + + // Test creating a POS app successfully + var app = await client.CreatePointOfSaleApp( + user.StoreId, + new CreatePointOfSaleAppRequest() + { + AppName = "test app from API", + Currency = "JPY" + } + ); Assert.Equal("test app from API", app.Name); Assert.Equal(user.StoreId, app.StoreId); Assert.Equal("PointOfSale", app.AppType); @@ -220,6 +270,11 @@ namespace BTCPayServer.Tests Assert.Equal(app.StoreId, retrievedApp.StoreId); Assert.Equal(app.AppType, retrievedApp.AppType); + // Test that we can update the app data + await client.PutPointOfSaleApp(app.Id, new CreatePointOfSaleAppRequest() { AppName = "new app name" }); + retrievedApp = await client.GetApp(app.Id); + Assert.Equal("new app name", retrievedApp.Name); + // Make sure we return a 404 if we try to delete an app that doesn't exist await AssertHttpError(404, async () => { diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs index ae54f896b..9adffb42f 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs @@ -6,6 +6,7 @@ using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using BTCPayServer.Abstractions.Extensions; using Microsoft.AspNetCore.Authorization; @@ -22,33 +23,38 @@ namespace BTCPayServer.Controllers.Greenfield { private readonly AppService _appService; private readonly StoreRepository _storeRepository; + private readonly CurrencyNameTable _currencies; public GreenfieldAppsController( AppService appService, StoreRepository storeRepository, UserManager userManager, - BTCPayNetworkProvider btcPayNetworkProvider + BTCPayNetworkProvider btcPayNetworkProvider, + CurrencyNameTable currencies ) { _appService = appService; _storeRepository = storeRepository; + _currencies = currencies; } [HttpPost("~/api/v1/stores/{storeId}/apps/pos")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task CreatePointOfSaleApp(string storeId, CreatePointOfSaleAppRequest request) { - var validationResult = Validate(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.Currency = request.Currency ?? store.GetStoreBlob().DefaultCurrency; + + var validationResult = ValidatePOSAppRequest(request); if (validationResult != null) { return validationResult; } - var store = await _storeRepository.FindStore(storeId); - if (store == null) - return this.CreateAPIError(404, "store-not-found", "The store was not found"); - - var defaultCurrency = store.GetStoreBlob().DefaultCurrency; var appData = new AppData { StoreDataId = storeId, @@ -56,36 +62,55 @@ namespace BTCPayServer.Controllers.Greenfield AppType = AppType.PointOfSale.ToString() }; - appData.SetSettings(new PointOfSaleSettings - { - Title = request.Title, - DefaultView = (Services.Apps.PosViewType)request.DefaultView, - ShowCustomAmount = request.ShowCustomAmount, - ShowDiscount = request.ShowDiscount, - EnableTips = request.EnableTips, - Currency = request.Currency ?? defaultCurrency, - Template = request.Template, - ButtonText = request.FixedAmountPayButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF, - CustomButtonText = request.CustomAmountPayButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF, - CustomTipText = request.TipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF, - CustomCSSLink = request.CustomCSSLink, - NotificationUrl = request.NotificationUrl, - RedirectUrl = request.RedirectUrl, - Description = request.Description, - EmbeddedCSS = request.EmbeddedCSS, - RedirectAutomatically = request.RedirectAutomatically, - RequiresRefundEmail = request.RequiresRefundEmail == true ? - RequiresRefundEmail.On : - request.RequiresRefundEmail == false ? - RequiresRefundEmail.Off : - RequiresRefundEmail.InheritFromStore, - }); + appData.SetSettings(ToPointOfSaleSettings(request)); await _appService.UpdateOrCreateApp(appData); return Ok(ToModel(appData)); } + [HttpPut("~/api/v1/apps/pos/{appId}")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task PutPointOfSaleApp(string appId, CreatePointOfSaleAppRequest request) + { + var app = await _appService.GetApp(appId, AppType.PointOfSale); + if (app == null) + { + return AppNotFound(); + } + + var settings = app.GetSettings(); + + // This is not obvious but we must have a non-null currency or else request validation may work incorrectly + request.Currency = request.Currency ?? settings.Currency; + + var validationResult = ValidatePOSAppRequest(request); + if (validationResult != null) + { + return validationResult; + } + + app.Name = request.AppName; + app.SetSettings(ToPointOfSaleSettings(request)); + + await _appService.UpdateOrCreateApp(app); + + return Ok(ToModel(app)); + } + + private RequiresRefundEmail? BoolToRequiresRefundEmail(bool? requiresRefundEmail) + { + switch (requiresRefundEmail) + { + case true: + return RequiresRefundEmail.On; + case false: + return RequiresRefundEmail.Off; + default: + return null; + } + } + [HttpGet("~/api/v1/apps/{appId}")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task GetApp(string appId) @@ -118,19 +143,73 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found"); } + private PointOfSaleSettings ToPointOfSaleSettings(CreatePointOfSaleAppRequest request) + { + return new PointOfSaleSettings() + { + Title = request.Title, + DefaultView = (Services.Apps.PosViewType)request.DefaultView, + ShowCustomAmount = request.ShowCustomAmount, + ShowDiscount = request.ShowDiscount, + EnableTips = request.EnableTips, + Currency = request.Currency, + Template = request.Template != null ? _appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency)) : null, + ButtonText = request.FixedAmountPayButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF, + CustomButtonText = request.CustomAmountPayButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF, + CustomTipText = request.TipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF, + CustomCSSLink = request.CustomCSSLink, + NotificationUrl = request.NotificationUrl, + RedirectUrl = request.RedirectUrl, + Description = request.Description, + EmbeddedCSS = request.EmbeddedCSS, + RedirectAutomatically = request.RedirectAutomatically, + RequiresRefundEmail = BoolToRequiresRefundEmail(request.RequiresRefundEmail) ?? RequiresRefundEmail.InheritFromStore, + }; + } + private PointOfSaleAppData ToModel(AppData appData) { + var settings = appData.GetSettings(); + return new PointOfSaleAppData { Id = appData.Id, AppType = appData.AppType, Name = appData.Name, StoreId = appData.StoreDataId, - Created = appData.Created + Created = appData.Created, }; } - private IActionResult? Validate(CreateAppRequest request) + private IActionResult? ValidatePOSAppRequest(CreatePointOfSaleAppRequest request) + { + var validationResult = ValidateCreateAppRequest(request); + if (request.Currency != null && _currencies.GetCurrencyData(request.Currency, false) == null) + { + ModelState.AddModelError(nameof(request.Currency), "Invalid currency"); + } + + if (request.Template != null) + { + try + { + _appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency)); + } + catch + { + ModelState.AddModelError(nameof(request.Template), "Invalid template"); + } + } + + 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 fb4e0f77f..45aad7abf 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -1103,6 +1103,14 @@ namespace BTCPayServer.Controllers.Greenfield await GetController().CreatePointOfSaleApp(storeId, request)); } + public override async Task PutPointOfSaleApp( + string appId, + CreatePointOfSaleAppRequest request, CancellationToken token = default) + { + return GetFromActionResult( + await GetController().PutPointOfSaleApp(appId, 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 c24358d98..342844bab 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json @@ -17,128 +17,83 @@ "summary": "Create a new Point of Sale app", "description": "Point of Sale apps allows accepting payments for items in a virtual store", "requestBody": { + "x-name": "request", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "appName": { - "type": "string", - "description": "The name of the app (shown in admin UI)", - "nullable": false - }, - "title": { - "type": "string", - "description": "The title of the app (shown to the user)", - "nullable": true - }, - "description": { - "type": "string", - "description": "The description of the app", - "nullable": true - }, - "template": { - "type": "string", - "description": "Template for items available in the app", - "nullable": true - }, - "defaultView": { - "type": "string", - "description": "Template for items available in the app", - "nullable": true, - "x-enumNames": [ - "Static", - "Cart", - "Light", - "Print" - ], - "enum": [ - "Static", - "Cart", - "Light", - "Print" - ] - }, - "currency": { - "type": "string", - "description": "Currency to use for the app. Defaults to the currency used by the store if not specified", - "example": "BTC", - "nullable": true - }, - "showCustomAmount": { - "type": "boolean", - "description": "Whether to include a special item in the store which allows user to input a custom payment amount", - "default": true, - "nullable": true - }, - "showDiscount": { - "type": "boolean", - "description": "Whether to allow user to input a discount amount. Applies to Cart view only. Not recommended for customer self-checkout", - "default": true, - "nullable": true - }, - "enableTips": { - "type": "boolean", - "description": "Whether to allow user to input a tip amount. Applies to Cart and Light views only", - "default": true, - "nullable": true - }, - "customAmountPayButtonText": { - "type": "string", - "description": "Payment button text which appears for items which allow user to input a custom amount", - "default": "Pay", - "nullable": true - }, - "fixedAmountPayButtonText": { - "type": "string", - "description": "Payment button text which appears for items which have a fixed price", - "default": "Buy for {PRICE_HERE}", - "nullable": true - }, - "tipText": { - "type": "string", - "description": "Prompt which appears next to the tip amount field if tipping is enabled", - "default": "Do you want to leave a tip?", - "nullable": true - }, - "customCSSLink": { - "type": "string", - "description": "Link to a custom CSS stylesheet to be used in the app", - "nullable": true - }, - "embeddedCSS": { - "type": "string", - "description": "Custom CSS to embed into the app", - "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 - }, - "redirectUrl": { - "type": "string", - "description": "URL to redirect user to once invoice is paid", - "nullable": true - }, - "redirectAutomatically": { - "type": "boolean", - "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", - "nullable": true - }, - "requiresRefundEmail": { - "type": "boolean", - "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", - "nullable": true - } + "$ref": "#/components/schemas/CreatePointOfSaleAppRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Created app details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PointOfSaleAppData" + } + } + } + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" } } } } }, + "tags": [ + "Apps" + ], + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/apps/pos/{appId}": { + "parameters": [ + { + "name": "appId", + "in": "path", + "required": true, + "description": "App ID", + "schema": { + "type": "string" + } + } + ], + "patch": { + "operationId": "Apps_PatchPointOfSaleApp", + "summary": "Update a Point of Sale app", + "description": "Use this endpoint for updating the properties of a POS app", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePointOfSaleAppRequest" + } + } + }, + "required": true, + "x-position": 1 + }, "responses": { "200": { - "description": "Created app details", + "description": "App details", "content": { "application/json": { "schema": { @@ -291,6 +246,120 @@ "description": "Type of the app which was created" } } + }, + "CreatePointOfSaleAppRequest": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "description": "The name of the app (shown in admin UI)", + "nullable": false + }, + "title": { + "type": "string", + "description": "The title of the app (shown to the user)", + "nullable": true + }, + "description": { + "type": "string", + "description": "The description of the app", + "nullable": true + }, + "template": { + "type": "string", + "description": "Template for items available in the app", + "nullable": true + }, + "defaultView": { + "type": "string", + "description": "Template for items available in the app", + "nullable": true, + "x-enumNames": [ + "Static", + "Cart", + "Light", + "Print" + ], + "enum": [ + "Static", + "Cart", + "Light", + "Print" + ] + }, + "currency": { + "type": "string", + "description": "Currency to use for the app. Defaults to the currency used by the store if not specified", + "example": "BTC", + "nullable": true + }, + "showCustomAmount": { + "type": "boolean", + "description": "Whether to include a special item in the store which allows user to input a custom payment amount", + "default": true, + "nullable": true + }, + "showDiscount": { + "type": "boolean", + "description": "Whether to allow user to input a discount amount. Applies to Cart view only. Not recommended for customer self-checkout", + "default": true, + "nullable": true + }, + "enableTips": { + "type": "boolean", + "description": "Whether to allow user to input a tip amount. Applies to Cart and Light views only", + "default": true, + "nullable": true + }, + "customAmountPayButtonText": { + "type": "string", + "description": "Payment button text which appears for items which allow user to input a custom amount", + "default": "Pay", + "nullable": true + }, + "fixedAmountPayButtonText": { + "type": "string", + "description": "Payment button text which appears for items which have a fixed price", + "default": "Buy for {PRICE_HERE}", + "nullable": true + }, + "tipText": { + "type": "string", + "description": "Prompt which appears next to the tip amount field if tipping is enabled", + "default": "Do you want to leave a tip?", + "nullable": true + }, + "customCSSLink": { + "type": "string", + "description": "Link to a custom CSS stylesheet to be used in the app", + "nullable": true + }, + "embeddedCSS": { + "type": "string", + "description": "Custom CSS to embed into the app", + "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 + }, + "redirectUrl": { + "type": "string", + "description": "URL to redirect user to once invoice is paid", + "nullable": true + }, + "redirectAutomatically": { + "type": "boolean", + "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", + "nullable": true + }, + "requiresRefundEmail": { + "type": "boolean", + "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", + "nullable": true + } + } } } },