Add support for updating POS app through Greenfield API

Part of #3458
This commit is contained in:
Umar Bolatov 2022-07-17 22:23:22 -07:00 committed by Andrew Camilleri
parent 701ba59bd8
commit 16f4ca5fbf
5 changed files with 370 additions and 148 deletions

View file

@ -20,6 +20,17 @@ namespace BTCPayServer.Client
return await HandleResponse<PointOfSaleAppData>(response);
}
public virtual async Task<PointOfSaleAppData> 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<PointOfSaleAppData>(response);
}
public virtual async Task<AppDataBase> GetApp(string appId, CancellationToken token = default)
{
if (appId == null)

View file

@ -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 () =>
{

View file

@ -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<ApplicationUser> 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<IActionResult> 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<IActionResult> PutPointOfSaleApp(string appId, CreatePointOfSaleAppRequest request)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null)
{
return AppNotFound();
}
var settings = app.GetSettings<PointOfSaleSettings>();
// 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<IActionResult> 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<PointOfSaleSettings>();
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)
{

View file

@ -1103,6 +1103,14 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldAppsController>().CreatePointOfSaleApp(storeId, request));
}
public override async Task<PointOfSaleAppData> PutPointOfSaleApp(
string appId,
CreatePointOfSaleAppRequest request, CancellationToken token = default)
{
return GetFromActionResult<PointOfSaleAppData>(
await GetController<GreenfieldAppsController>().PutPointOfSaleApp(appId, request));
}
public override async Task<AppDataBase> GetApp(string appId, CancellationToken token = default)
{
return GetFromActionResult<AppDataBase>(

View file

@ -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
}
}
}
}
},