Add crowdfund app create endpoint (#4068)

* Add crowdfund app create endpoint

* replace DateTimeJsonConverter with NBitcoin.JsonConverters.DateTimeToUnixTimeConverter

* Use DateTimeOffset instead of DateTime

* Use array instead of CSV

* update "startDate" and "endDate" docs definition

* update docs
This commit is contained in:
Umar Bolatov 2022-11-17 21:20:07 -08:00 committed by GitHub
parent 3942463ac9
commit 52af129c8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 576 additions and 6 deletions

View file

@ -19,6 +19,17 @@ namespace BTCPayServer.Client
method: HttpMethod.Post), token); method: HttpMethod.Post), token);
return await HandleResponse<PointOfSaleAppData>(response); return await HandleResponse<PointOfSaleAppData>(response);
} }
public virtual async Task<CrowdfundAppData> 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<CrowdfundAppData>(response);
}
public virtual async Task<PointOfSaleAppData> UpdatePointOfSaleApp(string appId, public virtual async Task<PointOfSaleAppData> UpdatePointOfSaleApp(string appId,
CreatePointOfSaleAppRequest request, CancellationToken token = default) CreatePointOfSaleAppRequest request, CancellationToken token = default)

View file

@ -1,3 +1,4 @@
using System;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@ -40,4 +41,44 @@ namespace BTCPayServer.Client.Models
public string EmbeddedCSS { get; set; } = null; public string EmbeddedCSS { get; set; } = null;
public CheckoutType? CheckoutType { 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;
}
} }

View file

@ -17,4 +17,9 @@ namespace BTCPayServer.Client.Models
{ {
// We can add POS specific things here later // We can add POS specific things here later
} }
public class CrowdfundAppData : AppDataBase
{
// We can add Crowdfund specific things here later
}
} }

View file

@ -288,6 +288,117 @@ namespace BTCPayServer.Tests
await client.GetApp(retrievedApp.Id); 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)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]

View file

@ -1,5 +1,6 @@
#nullable enable #nullable enable
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client; using BTCPayServer.Client;
@ -38,6 +39,37 @@ namespace BTCPayServer.Controllers.Greenfield
_currencies = currencies; _currencies = currencies;
} }
[HttpPost("~/api/v1/stores/{storeId}/apps/crowdfund")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> 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")] [HttpPost("~/api/v1/stores/{storeId}/apps/pos")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreatePointOfSaleApp(string storeId, CreatePointOfSaleAppRequest request) public async Task<IActionResult> CreatePointOfSaleApp(string storeId, CreatePointOfSaleAppRequest request)
@ -66,7 +98,7 @@ namespace BTCPayServer.Controllers.Greenfield
await _appService.UpdateOrCreateApp(appData); await _appService.UpdateOrCreateApp(appData);
return Ok(ToModel(appData)); return Ok(ToPointOfSaleModel(appData));
} }
[HttpPut("~/api/v1/apps/pos/{appId}")] [HttpPut("~/api/v1/apps/pos/{appId}")]
@ -95,7 +127,7 @@ namespace BTCPayServer.Controllers.Greenfield
await _appService.UpdateOrCreateApp(app); await _appService.UpdateOrCreateApp(app);
return Ok(ToModel(app)); return Ok(ToPointOfSaleModel(app));
} }
private RequiresRefundEmail? BoolToRequiresRefundEmail(bool? requiresRefundEmail) private RequiresRefundEmail? BoolToRequiresRefundEmail(bool? requiresRefundEmail)
@ -115,7 +147,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetApp(string appId) public async Task<IActionResult> GetApp(string appId)
{ {
var app = await _appService.GetApp(appId, AppType.PointOfSale); var app = await _appService.GetApp(appId, null);
if (app == null) if (app == null)
{ {
return AppNotFound(); 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"); 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) private PointOfSaleSettings ToPointOfSaleSettings(CreatePointOfSaleAppRequest request)
{ {
return new PointOfSaleSettings() 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<PointOfSaleSettings>(); 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 return new PointOfSaleAppData
{ {
Id = appData.Id, Id = appData.Id,
@ -211,6 +290,84 @@ namespace BTCPayServer.Controllers.Greenfield
return validationResult; 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) private IActionResult? ValidateCreateAppRequest(CreateAppRequest request)
{ {
if (request is null) if (request is null)

View file

@ -1163,6 +1163,14 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldAppsController>().UpdatePointOfSaleApp(appId, request)); await GetController<GreenfieldAppsController>().UpdatePointOfSaleApp(appId, request));
} }
public override async Task<CrowdfundAppData> CreateCrowdfundApp(
string storeId,
CreateCrowdfundAppRequest request, CancellationToken token = default)
{
return GetFromActionResult<CrowdfundAppData>(
await GetController<GreenfieldAppsController>().CreateCrowdfundApp(storeId, request));
}
public override async Task<AppDataBase> GetApp(string appId, CancellationToken token = default) public override async Task<AppDataBase> GetApp(string appId, CancellationToken token = default)
{ {
return GetFromActionResult<AppDataBase>( return GetFromActionResult<AppDataBase>(

View file

@ -15,7 +15,7 @@
"post": { "post": {
"operationId": "Apps_CreatePointOfSaleApp", "operationId": "Apps_CreatePointOfSaleApp",
"summary": "Create a new Point of Sale app", "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": { "requestBody": {
"x-name": "request", "x-name": "request",
"content": { "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}": { "/api/v1/apps/{appId}": {
"get": { "get": {
"tags": [ "tags": [
@ -217,6 +280,15 @@
} }
] ]
}, },
"CrowdfundAppData": {
"allOf": [
{
"$ref": "#/components/schemas/BasicAppData"
},
{
}
]
},
"BasicAppData": { "BasicAppData": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -368,6 +440,171 @@
"nullable": true "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"]
}
}
} }
} }
}, },