POS: Create Invoice action optionally responds with JSON (#6439)

* POS: Create Invoice action optionally responds with JSON

We adapted this action, which is full of custom POS logic, for the app to avoid creating a separate API endpoint.

* Add test and improve error handling
This commit is contained in:
d11n
2024-12-13 04:09:55 +01:00
committed by GitHub
parent d4b76823a2
commit 7829a93251
2 changed files with 86 additions and 12 deletions

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
@@ -802,5 +803,65 @@ g:
Assert.Equal("new", topupInvoice.Status);
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUsePoSAppJsonEndpoint()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var apps = user.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = PointOfSaleAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
apps.HttpContext.SetAppData(appData);
pos.HttpContext.SetAppData(appData);
var vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "App POS";
vmpos.Currency = "EUR";
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
// Failing requests
var (invoiceId1, error1) = await PosJsonRequest(tester, app.Id, "amount=-21&discount=10&tip=2");
Assert.Null(invoiceId1);
Assert.Equal("Negative amount is not allowed", error1);
var (invoiceId2, error2) = await PosJsonRequest(tester, app.Id, "amount=21&discount=-10&tip=-2");
Assert.Null(invoiceId2);
Assert.Equal("Negative tip or discount is not allowed", error2);
// Successful request
var (invoiceId3, error3) = await PosJsonRequest(tester, app.Id, "amount=21");
Assert.NotNull(invoiceId3);
Assert.Null(error3);
// Check generated invoice
var invoices = await user.BitPay.GetInvoicesAsync();
var invoice = invoices.First();
Assert.Equal(invoiceId3, invoice.Id);
Assert.Equal(21.00m, invoice.Price);
Assert.Equal("EUR", invoice.Currency);
}
private async Task<(string invoiceId, string error)> PosJsonRequest(ServerTester tester, string appId, string query)
{
var uriBuilder = new UriBuilder(tester.PayTester.ServerUri) { Path = $"/apps/{appId}/pos/light", Query = query };
var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
request.Headers.Add("Accept", "application/json");
var response = await tester.PayTester.HttpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(content);
return (json["invoiceId"]?.Value<string>(), json["error"]?.Value<string>());
}
}
}

View File

@@ -160,17 +160,25 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (await Throttle(appId))
return new TooManyRequestsResult(ZoneLimits.PublicInvoices);
// Distinguish JSON requests coming via the mobile app
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
if (app == null)
return wantsJson
? Json(new { error = "App not found" })
: NotFound();
// not allowing negative tips or discounts
if (tip < 0 || discount < 0)
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
return wantsJson
? Json(new { error = "Negative tip or discount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
if (app == null)
return NotFound();
return wantsJson
? Json(new { error = "Negative amount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
@@ -180,6 +188,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
}
var jposData = TryParseJObject(posData);
string title;
decimal? price;
@@ -235,9 +244,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
switch (itemChoice.Inventory)
{
case <= 0:
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
case { } inventory when inventory < cartItem.Count:
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
return wantsJson
? Json(new { error = $"Inventory for {itemChoice.Title} exhausted: {itemChoice.Inventory} available" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
}
@@ -262,8 +272,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var store = await _appService.GetStore(app);
var storeBlob = store.GetStoreBlob();
var posFormId = settings.FormId;
var formData = await FormDataService.GetForm(posFormId);
// skip forms feature for JSON requests (from the app)
var formData = wantsJson ? null : await FormDataService.GetForm(posFormId);
JObject formResponseJObject = null;
switch (formData)
{
@@ -411,14 +422,16 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
meta.Merge(formResponseJObject);
entity.Metadata = InvoiceMetadata.FromJObject(meta);
});
var data = new { invoiceId = invoice.Id };
if (wantsJson)
return Json(data);
if (price is 0 && storeBlob.ReceiptOptions?.Enabled is true)
{
return RedirectToAction(nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", new { invoiceId = invoice.Id });
}
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Id });
return RedirectToAction(nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", data);
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", data);
}
catch (BitpayHttpException e)
{
if (wantsJson) return Json(new { error = e.Message });
TempData.SetStatusMessageModel(new StatusMessageModel
{
Html = e.Message.Replace("\n", "<br />", StringComparison.OrdinalIgnoreCase),