From 7829a932514111f9bd00267f4d02d1a0d112e7be Mon Sep 17 00:00:00 2001 From: d11n Date: Fri, 13 Dec 2024 04:09:55 +0100 Subject: [PATCH] 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 --- .../AltcoinTests/AltcoinTests.cs | 61 +++++++++++++++++++ .../Controllers/UIPointOfSaleController.cs | 37 +++++++---- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs index 02e25ac6d..d2894f449 100644 --- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs +++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs @@ -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(); + var pos = user.GetController(); + var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); + var appType = PointOfSaleAppType.AppType; + vm.AppName = "test"; + vm.SelectedAppType = appType; + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/pos", redirect.Url); + var appList = Assert.IsType(Assert.IsType(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(); + vmpos.Title = "App POS"; + vmpos.Currency = "EUR"; + Assert.IsType(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(), json["error"]?.Value()); + } } } diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index c26b2d7c4..32e08023a 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -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(); 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", "
", StringComparison.OrdinalIgnoreCase),