Merge pull request #3511 from bolatovumar/feat/api/apps

Add support for creating POS apps through Greenfield API
This commit is contained in:
Nicolas Dorier 2022-06-02 14:13:25 +09:00 committed by GitHub
commit fcd6159b42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 514 additions and 81 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

@ -0,0 +1,41 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public enum PosViewType
{
Static,
Cart,
Light,
Print
}
public class CreateAppRequest
{
public string AppName { 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;
[JsonConverter(typeof(StringEnumConverter))]
public PosViewType DefaultView { get; set; }
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

@ -179,6 +179,22 @@ namespace BTCPayServer.Tests
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)]
[Trait("Integration", "Integration")]
public async Task CanDeleteUsersViaApi()

View File

@ -203,7 +203,8 @@ namespace BTCPayServer.Tests
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
request.Headers.TryAddWithoutValidation("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:75.0) Gecko/20100101 Firefox/75.0");
var response = await httpClient.SendAsync(request);
using var cts = new CancellationTokenSource(5_000);
var response = await httpClient.SendAsync(request, cts.Token);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
if (uri.Fragment.Length != 0)
{
@ -223,11 +224,11 @@ namespace BTCPayServer.Tests
}
catch (Exception) when (retryLeft > 0)
{
retryLeft--;
goto retry;
}
catch (Exception ex)
{
retryLeft--;
var details = ex is EqualException ? (ex as EqualException).Actual : ex.Message;
TestLogs.LogInformation($"FAILED: {url} ({file}) {details}");

View File

@ -0,0 +1,116 @@
#nullable enable
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using BTCPayServer.Abstractions.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldAppsController : ControllerBase
{
private readonly AppService _appService;
private readonly StoreRepository _storeRepository;
public GreenfieldAppsController(
AppService appService,
StoreRepository storeRepository,
UserManager<ApplicationUser> userManager,
BTCPayNetworkProvider btcPayNetworkProvider
)
{
_appService = appService;
_storeRepository = storeRepository;
}
[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);
if (validationResult != null)
{
return validationResult;
}
var defaultCurrency = (await _storeRepository.FindStore(storeId)).GetStoreBlob().DefaultCurrency;
var appData = new AppData
{
StoreDataId = storeId,
Name = request.AppName,
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,
});
await _appService.UpdateOrCreateApp(appData);
return Ok(ToModel(appData));
}
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)
{
return BadRequest();
}
if (string.IsNullOrEmpty(request.AppName))
{
ModelState.AddModelError(nameof(request.AppName), "App name is missing");
}
else if (request.AppName.Length < 1 || request.AppName.Length > 50)
{
ModelState.AddModelError(nameof(request.AppName), "Name can only be between 1 and 50 characters");
}
return !ModelState.IsValid ? this.CreateValidationError(ModelState) : null;
}
}
}

View File

@ -15,80 +15,6 @@ namespace BTCPayServer.Controllers
{
public partial class UIAppsController
{
public class PointOfSaleSettings
{
public PointOfSaleSettings()
{
Title = "Tea shop";
Template =
"green tea:\n" +
" price: 1\n" +
" title: Green Tea\n" +
" description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.\n" +
" image: ~/img/pos-sample/green-tea.jpg\n\n" +
"black tea:\n" +
" price: 1\n" +
" title: Black Tea\n" +
" description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.\n" +
" image: ~/img/pos-sample/black-tea.jpg\n\n" +
"rooibos:\n" +
" price: 1.2\n" +
" title: Rooibos\n" +
" description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.\n" +
" image: ~/img/pos-sample/rooibos.jpg\n\n" +
"pu erh:\n" +
" price: 2\n" +
" title: Pu Erh\n" +
" description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.\n" +
" image: ~/img/pos-sample/pu-erh.jpg\n\n" +
"herbal tea:\n" +
" price: 1.8\n" +
" title: Herbal Tea\n" +
" description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!\n" +
" image: ~/img/pos-sample/herbal-tea.jpg\n" +
" custom: true\n\n" +
"fruit tea:\n" +
" price: 1.5\n" +
" title: Fruit Tea\n" +
" description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" +
" image: ~/img/pos-sample/fruit-tea.jpg\n" +
" inventory: 5\n" +
" custom: true";
DefaultView = PosViewType.Static;
ShowCustomAmount = true;
ShowDiscount = true;
EnableTips = true;
RequiresRefundEmail = RequiresRefundEmail.InheritFromStore;
}
public string Title { get; set; }
public string Currency { get; set; }
public string Template { get; set; }
public bool EnableShoppingCart { get; set; }
public PosViewType DefaultView { get; set; }
public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; }
public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; }
public const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF;
public const string CUSTOM_BUTTON_TEXT_DEF = "Pay";
public string CustomButtonText { get; set; } = CUSTOM_BUTTON_TEXT_DEF;
public const string CUSTOM_TIP_TEXT_DEF = "Do you want to leave a tip?";
public string CustomTipText { get; set; } = CUSTOM_TIP_TEXT_DEF;
public static readonly int[] CUSTOM_TIP_PERCENTAGES_DEF = new int[] { 15, 18, 20 };
public int[] CustomTipPercentages { get; set; } = CUSTOM_TIP_PERCENTAGES_DEF;
public string CustomCSSLink { get; set; }
public string EmbeddedCSS { get; set; }
public string Description { get; set; }
public string NotificationUrl { get; set; }
public string RedirectUrl { get; set; }
public bool? RedirectAutomatically { get; set; }
}
[HttpGet("{appId}/settings/pos")]
public IActionResult UpdatePointOfSale(string appId)
{

View File

@ -115,7 +115,7 @@ namespace BTCPayServer
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
break;
case nameof(AppType.PointOfSale):
var posS = app.GetSettings<UIAppsController.PointOfSaleSettings>();
var posS = app.GetSettings<PointOfSaleSettings>();
currencyCode = posS.Currency;
items = _appService.Parse(posS.Template, posS.Currency);
break;

View File

@ -37,7 +37,7 @@ namespace BTCPayServer.HostedServices
switch (Enum.Parse<AppType>(data.AppType))
{
case AppType.PointOfSale:
var possettings = data.GetSettings<UIAppsController.PointOfSaleSettings>();
var possettings = data.GetSettings<PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _appService.Parse(possettings.Template, possettings.Currency));
case AppType.Crowdfund:
@ -69,7 +69,7 @@ namespace BTCPayServer.HostedServices
{
case AppType.PointOfSale:
((UIAppsController.PointOfSaleSettings)valueTuple.Settings).Template =
((PointOfSaleSettings)valueTuple.Settings).Template =
_appService.SerializeTemplate(valueTuple.Items);
break;
case AppType.Crowdfund:

View File

@ -350,7 +350,7 @@ WHERE cte.""Id""=p.""Id""
case nameof(AppType.PointOfSale):
var settings2 = app.GetSettings<UIAppsController.PointOfSaleSettings>();
var settings2 = app.GetSettings<PointOfSaleSettings>();
if (string.IsNullOrEmpty(settings2.Currency))
{
settings2.Currency = app.StoreData.GetStoreBlob().DefaultCurrency;

View File

@ -347,7 +347,7 @@ namespace BTCPayServer.Services.Apps
{
AppType appTypeEnum = Enum.Parse<AppType>(appType);
AppData appData = await GetApp(appId, appTypeEnum, false);
var settings = appData.GetSettings<UIAppsController.PointOfSaleSettings>();
var settings = appData.GetSettings<PointOfSaleSettings>();
string style;
switch (appTypeEnum)

View File

@ -0,0 +1,76 @@
namespace BTCPayServer.Services.Apps
{
public class PointOfSaleSettings
{
public PointOfSaleSettings()
{
Title = "Tea shop";
Template =
"green tea:\n" +
" price: 1\n" +
" title: Green Tea\n" +
" description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.\n" +
" image: ~/img/pos-sample/green-tea.jpg\n\n" +
"black tea:\n" +
" price: 1\n" +
" title: Black Tea\n" +
" description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.\n" +
" image: ~/img/pos-sample/black-tea.jpg\n\n" +
"rooibos:\n" +
" price: 1.2\n" +
" title: Rooibos\n" +
" description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.\n" +
" image: ~/img/pos-sample/rooibos.jpg\n\n" +
"pu erh:\n" +
" price: 2\n" +
" title: Pu Erh\n" +
" description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.\n" +
" image: ~/img/pos-sample/pu-erh.jpg\n\n" +
"herbal tea:\n" +
" price: 1.8\n" +
" title: Herbal Tea\n" +
" description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!\n" +
" image: ~/img/pos-sample/herbal-tea.jpg\n" +
" custom: true\n\n" +
"fruit tea:\n" +
" price: 1.5\n" +
" title: Fruit Tea\n" +
" description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" +
" image: ~/img/pos-sample/fruit-tea.jpg\n" +
" inventory: 5\n" +
" custom: true";
DefaultView = PosViewType.Static;
ShowCustomAmount = true;
ShowDiscount = true;
EnableTips = true;
RequiresRefundEmail = RequiresRefundEmail.InheritFromStore;
}
public string Title { get; set; }
public string Currency { get; set; }
public string Template { get; set; }
public bool EnableShoppingCart { get; set; }
public PosViewType DefaultView { get; set; }
public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; }
public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; }
public const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF;
public const string CUSTOM_BUTTON_TEXT_DEF = "Pay";
public string CustomButtonText { get; set; } = CUSTOM_BUTTON_TEXT_DEF;
public const string CUSTOM_TIP_TEXT_DEF = "Do you want to leave a tip?";
public string CustomTipText { get; set; } = CUSTOM_TIP_TEXT_DEF;
public static readonly int[] CUSTOM_TIP_PERCENTAGES_DEF = new int[] { 15, 18, 20 };
public int[] CustomTipPercentages { get; set; } = CUSTOM_TIP_PERCENTAGES_DEF;
public string CustomCSSLink { get; set; }
public string EmbeddedCSS { get; set; }
public string Description { get; set; }
public string NotificationUrl { get; set; }
public string RedirectUrl { get; set; }
public bool? RedirectAutomatically { get; set; }
}
}

View File

@ -0,0 +1,214 @@
{
"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": [
"Apps"
],
"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": "Apps"
}
]
}