Add endpoint for creating POS app

This commit is contained in:
Umar Bolatov
2022-05-01 22:28:27 -07:00
parent aa3c0346c8
commit fca066fe52
6 changed files with 347 additions and 32 deletions

View File

@@ -0,0 +1,23 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<PointOfSaleAppData> CreatePointOfSaleApp(string storeId,
CreatePointOfSaleAppRequest request, CancellationToken token = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/apps/pos", bodyPayload: request,
method: HttpMethod.Post), token);
return await HandleResponse<PointOfSaleAppData>(response);
}
}
}

View File

@@ -2,8 +2,28 @@ namespace BTCPayServer.Client.Models
{ {
public class CreateAppRequest public class CreateAppRequest
{ {
public string StoreId { get; set; }
public string AppName { get; set; } public string AppName { get; set; }
public string AppType { get; set; } public string AppType { get; set; }
} }
public class CreatePointOfSaleAppRequest : CreateAppRequest
{
public string Currency { get; set; } = null;
public string Title { get; set; } = null;
public string Description { get; set; } = null;
public string Template { get; set; } = null;
public string DefaultView { get; set; } = null;
public bool ShowCustomAmount { get; set; } = true;
public bool ShowDiscount { get; set; } = true;
public bool EnableTips { get; set; } = true;
public string CustomAmountPayButtonText { get; set; } = null;
public string FixedAmountPayButtonText { get; set; } = null;
public string TipText { get; set; } = null;
public string CustomCSSLink { get; set; } = null;
public string NotificationUrl { get; set; } = null;
public string RedirectUrl { get; set; } = null;
public bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { get; set; } = null;
public string EmbeddedCSS { get; set; } = null;
}
} }

View File

@@ -0,0 +1,20 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class AppDataBase
{
public string Id { get; set; }
public string AppType { get; set; }
public string Name { get; set; }
public string StoreId { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Created { get; set; }
}
public class PointOfSaleAppData : AppDataBase
{
// We can add POS specific things here later
}
}

View File

@@ -174,6 +174,22 @@ namespace BTCPayServer.Tests
await AssertAPIError("apikey-not-found", () => unrestricted.RevokeAPIKey(apiKey.ApiKey)); await AssertAPIError("apikey-not-found", () => unrestricted.RevokeAPIKey(apiKey.ApiKey));
} }
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanCreatePointOfSaleAppViaAPI()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient();
var app = await client.CreatePointOfSaleApp(user.StoreId, new CreatePointOfSaleAppRequest() { AppName = "test app from API" });
Assert.Equal("test app from API", app.Name);
Assert.Equal(user.StoreId, app.StoreId);
Assert.Equal("PointOfSale", app.AppType);
}
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task CanDeleteUsersViaApi() public async Task CanDeleteUsersViaApi()

View File

@@ -34,54 +34,75 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository; _storeRepository = storeRepository;
} }
[HttpPost("~/api/v1/apps")] [HttpPost("~/api/v1/stores/{storeId}/apps/pos")]
[Authorize(Policy = Policies.CanModifyStoreSettingsUnscoped, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateApp(CreateAppRequest request) public async Task<IActionResult> CreatePointOfSaleApp(string storeId, CreatePointOfSaleAppRequest request)
{ {
var validationResult = await Validate(request); var validationResult = Validate(request);
if (validationResult != null) if (validationResult != null)
{ {
return validationResult; return validationResult;
} }
var defaultCurrency = (await _storeRepository.FindStore(storeId)).GetStoreBlob().DefaultCurrency;
var appData = new AppData var appData = new AppData
{ {
StoreDataId = request.StoreId, StoreDataId = storeId,
Name = request.AppName, Name = request.AppName,
AppType = request.AppType AppType = AppType.PointOfSale.ToString()
}; };
var defaultCurrency = (await _storeRepository.FindStore(request.StoreId)).GetStoreBlob().DefaultCurrency; Enum.TryParse(request.DefaultView, out PosViewType defaultView);
Enum.TryParse(request.AppType, out AppType appType);
switch (appType) appData.SetSettings(new PointOfSaleSettings
{ {
case AppType.Crowdfund: Title = request.Title,
var emptyCrowdfund = new CrowdfundSettings { TargetCurrency = defaultCurrency }; DefaultView = defaultView,
appData.SetSettings(emptyCrowdfund); ShowCustomAmount = request.ShowCustomAmount,
break; ShowDiscount = request.ShowDiscount,
case AppType.PointOfSale: EnableTips = request.EnableTips,
var empty = new PointOfSaleSettings { Currency = defaultCurrency }; Currency = request.Currency ?? defaultCurrency,
appData.SetSettings(empty); Template = request.Template,
break; ButtonText = request.FixedAmountPayButtonText,
} CustomButtonText = request.CustomAmountPayButtonText,
CustomTipText = request.TipText,
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,
});
await _appService.UpdateOrCreateApp(appData); await _appService.UpdateOrCreateApp(appData);
return Ok(appData); return Ok(ToModel(appData));
} }
async private Task<IActionResult?> Validate(CreateAppRequest request) private PointOfSaleAppData ToModel(AppData appData)
{
return new PointOfSaleAppData
{
Id = appData.Id,
AppType = appData.AppType,
Name = appData.Name,
StoreId = appData.StoreDataId,
Created = appData.Created
};
}
private IActionResult? Validate(CreateAppRequest request)
{ {
if (request is null) if (request is null)
{ {
return BadRequest(); return BadRequest();
} }
if (!Enum.TryParse(request.AppType, out AppType appType))
{
ModelState.AddModelError(nameof(request.AppType), "Invalid app type");
}
if (string.IsNullOrEmpty(request.AppName)) if (string.IsNullOrEmpty(request.AppName))
{ {
ModelState.AddModelError(nameof(request.AppName), "App name is missing"); ModelState.AddModelError(nameof(request.AppName), "App name is missing");
@@ -91,12 +112,6 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.AppName), "Name can only be between 1 and 50 characters"); ModelState.AddModelError(nameof(request.AppName), "Name can only be between 1 and 50 characters");
} }
var store = await _storeRepository.FindStore(request.StoreId);
if (store == null)
{
ModelState.AddModelError(nameof(request.StoreId), "Store with provided ID not found");
}
return !ModelState.IsValid ? this.CreateValidationError(ModelState) : null; return !ModelState.IsValid ? this.CreateValidationError(ModelState) : null;
} }
} }

View File

@@ -0,0 +1,221 @@
{
"paths": {
"/api/v1/stores/{storeId}/apps/pos": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store ID",
"schema": {
"type": "string"
}
}
],
"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",
"requestBody": {
"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
}
}
}
}
}
},
"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": [
"POS",
"Point of Sale"
],
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"PointOfSaleAppData": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Id of the app",
"example": "3ki4jsAkN4u9rv1PUzj1odX4Nx7s"
},
"name": {
"type": "string",
"description": "Name given to the app when it was created",
"example": "my test app"
},
"storeId": {
"type": "string",
"description": "Id of the store to which the app belongs",
"example": "9CiNzKoANXxmk5ayZngSXrHTiVvvgCrwrpFQd4m2K776"
},
"created": {
"type": "integer",
"example": 1651554744,
"description": "UNIX timestamp for when the app was created"
},
"appType": {
"type": "string",
"example": "PointOfSale",
"description": "Type of the app which was created (will always \"PointOfSale\" in this case"
}
}
}
}
},
"tags": [
{
"name": "App"
},
{
"name": "POS"
},
{
"name": "Point of Sale"
}
]
}