Remove JSON in strings from JObjects (#4703)

This commit is contained in:
Nicolas Dorier
2023-02-25 23:34:49 +09:00
committed by GitHub
parent e89b1826ce
commit c229425534
10 changed files with 363 additions and 241 deletions

View File

@@ -1218,21 +1218,14 @@ namespace BTCPayServer.Tests
{(null, new Dictionary<string, object>())}, {(null, new Dictionary<string, object>())},
{("", new Dictionary<string, object>())}, {("", new Dictionary<string, object>())},
{("{}", new Dictionary<string, object>())}, {("{}", new Dictionary<string, object>())},
{("non-json-content", new Dictionary<string, object>() {{string.Empty, "non-json-content"}})},
{("[1,2,3]", new Dictionary<string, object>() {{string.Empty, "[1,2,3]"}})},
{("{ \"key\": \"value\"}", new Dictionary<string, object>() {{"key", "value"}})}, {("{ \"key\": \"value\"}", new Dictionary<string, object>() {{"key", "value"}})},
{("{ \"key\": true}", new Dictionary<string, object>() {{"key", "True"}})},
{
("{ invalidjson file here}",
new Dictionary<string, object>() {{String.Empty, "{ invalidjson file here}"}})
},
// Duplicate keys should not crash things // Duplicate keys should not crash things
{("{ \"key\": true, \"key\": true}", new Dictionary<string, object>() {{"key", "True"}})} {("{ \"key\": true, \"key\": true}", new Dictionary<string, object>() {{"key", "True"}})}
}; };
testCases.ForEach(tuple => testCases.ForEach(tuple =>
{ {
Assert.Equal(tuple.expectedOutput, UIInvoiceController.PosDataParser.ParsePosData(tuple.input)); Assert.Equal(tuple.expectedOutput, UIInvoiceController.PosDataParser.ParsePosData(string.IsNullOrEmpty(tuple.input) ? null : JToken.Parse(tuple.input)));
}); });
} }
[Fact] [Fact]
@@ -1806,6 +1799,70 @@ namespace BTCPayServer.Tests
} }
} }
} }
[Fact]
public void CanParseMetadata()
{
var metadata = InvoiceMetadata.FromJObject(JObject.Parse("{\"posData\": {\"test\":\"a\"}}"));
Assert.Equal(JObject.Parse("{\"test\":\"a\"}").ToString(), metadata.PosDataLegacy);
Assert.Equal(JObject.Parse("{\"test\":\"a\"}").ToString(), metadata.PosData.ToString());
// Legacy, as string
metadata = InvoiceMetadata.FromJObject(JObject.Parse("{\"posData\": \"{\\\"test\\\":\\\"a\\\"}\"}"));
Assert.Equal("{\"test\":\"a\"}", metadata.PosDataLegacy);
Assert.Equal(JObject.Parse("{\"test\":\"a\"}").ToString(), metadata.PosData.ToString());
metadata = InvoiceMetadata.FromJObject(JObject.Parse("{\"posData\": \"nobject\"}"));
Assert.Equal("nobject", metadata.PosDataLegacy);
Assert.Null(metadata.PosData);
metadata = InvoiceMetadata.FromJObject(JObject.Parse("{\"posData\": null}"));
Assert.Null(metadata.PosDataLegacy);
Assert.Null(metadata.PosData);
metadata = InvoiceMetadata.FromJObject(JObject.Parse("{}"));
Assert.Null(metadata.PosDataLegacy);
Assert.Null(metadata.PosData);
}
[Fact]
public void CanParseInvoiceEntityDerivationStrategies()
{
// We have 3 ways of serializing the derivation strategies:
// through "derivationStrategy", through "derivationStrategies" as a string, through "derivationStrategies" as JObject
// Let's check that InvoiceEntity is similar in all cases.
var legacy = new JObject()
{
["derivationStrategy"] = "tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf"
};
var scheme = DerivationSchemeSettings.Parse("tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf", new BTCPayNetworkProvider(ChainName.Regtest).BTC);
scheme.Source = "ManualDerivationScheme";
scheme.AccountOriginal = "tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf";
var legacy2 = new JObject()
{
["derivationStrategies"] = scheme.ToJson()
};
var newformat = new JObject()
{
["derivationStrategies"] = JObject.Parse(scheme.ToJson())
};
//new BTCPayNetworkProvider(ChainName.Regtest)
#pragma warning disable CS0618 // Type or member is obsolete
var formats = new[] { legacy, legacy2, newformat }
.Select(o =>
{
var entity = JsonConvert.DeserializeObject<InvoiceEntity>(o.ToString());
entity.Networks = new BTCPayNetworkProvider(ChainName.Regtest);
return entity.DerivationStrategies.ToString();
})
.ToHashSet();
#pragma warning restore CS0618 // Type or member is obsolete
Assert.Equal(1, formats.Count);
}
[Fact] [Fact]
public void PaymentMethodIdConverterIsGraceful() public void PaymentMethodIdConverterIsGraceful()
{ {

View File

@@ -1695,37 +1695,17 @@ namespace BTCPayServer.Tests
var testCases = var testCases =
new List<(string input, Dictionary<string, object> expectedOutput)>() new List<(string input, Dictionary<string, object> expectedOutput)>()
{ {
{(null, new Dictionary<string, object>())},
{("", new Dictionary<string, object>())},
{("{}", new Dictionary<string, object>())},
{
("non-json-content",
new Dictionary<string, object>() {{string.Empty, "non-json-content"}})
},
{("[1,2,3]", new Dictionary<string, object>() {{string.Empty, "[1,2,3]"}})},
{("{ \"key\": \"value\"}", new Dictionary<string, object>() {{"key", "value"}})}, {("{ \"key\": \"value\"}", new Dictionary<string, object>() {{"key", "value"}})},
{("{ \"key\": true}", new Dictionary<string, object>() {{"key", "True"}})}, {("{ \"key\": true}", new Dictionary<string, object>() {{"key", "True"}})}
{
("{ invalidjson file here}",
new Dictionary<string, object>() {{String.Empty, "{ invalidjson file here}"}})
}
}; };
var tasks = new List<Task>();
foreach (var valueTuple in testCases) foreach (var valueTuple in testCases)
{ {
tasks.Add(user.BitPay.CreateInvoiceAsync(new Invoice(1, "BTC") { PosData = valueTuple.input }) var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice(1, "BTC") { PosData = valueTuple.input });
.ContinueWith(async task => var result = await controller.Invoice(invoice.Id);
{ var viewModel = result.AssertViewModel<InvoiceDetailsModel>();
var result = await controller.Invoice(task.Result.Id); Assert.Equal(valueTuple.expectedOutput, viewModel.AdditionalData["posData"]);
var viewModel =
Assert.IsType<InvoiceDetailsModel>(
Assert.IsType<ViewResult>(result).Model);
Assert.Equal(valueTuple.expectedOutput, viewModel.PosData);
}));
} }
await Task.WhenAll(tasks);
} }
[Fact(Timeout = LongRunningTestTimeout)] [Fact(Timeout = LongRunningTestTimeout)]

View File

@@ -40,6 +40,17 @@ namespace BTCPayServer.Controllers
{ {
public partial class UIInvoiceController public partial class UIInvoiceController
{ {
static UIInvoiceController()
{
InvoiceAdditionalDataExclude =
typeof(InvoiceMetadata)
.GetProperties()
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
InvoiceAdditionalDataExclude.Remove(nameof(InvoiceMetadata.PosData));
}
static readonly HashSet<string> InvoiceAdditionalDataExclude;
[HttpGet("invoices/{invoiceId}/deliveries/{deliveryId}/request")] [HttpGet("invoices/{invoiceId}/deliveries/{deliveryId}/request")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WebhookDelivery(string invoiceId, string deliveryId) public async Task<IActionResult> WebhookDelivery(string invoiceId, string deliveryId)
@@ -106,13 +117,9 @@ namespace BTCPayServer.Controllers
var receipt = InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, invoice.ReceiptOptions); var receipt = InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, invoice.ReceiptOptions);
var invoiceState = invoice.GetInvoiceState(); var invoiceState = invoice.GetInvoiceState();
var posData = PosDataParser.ParsePosData(invoice.Metadata.PosData); var metaData = PosDataParser.ParsePosData(invoice.Metadata.ToJObject());
var metaData = PosDataParser.ParsePosData(invoice.Metadata.ToJObject().ToString());
var excludes = typeof(InvoiceMetadata).GetProperties()
.Select(p => char.ToLowerInvariant(p.Name[0]) + p.Name[1..])
.ToList();
var additionalData = metaData var additionalData = metaData
.Where(dict => !excludes.Contains(dict.Key)) .Where(dict => !InvoiceAdditionalDataExclude.Contains(dict.Key))
.ToDictionary(dict=> dict.Key, dict=> dict.Value); .ToDictionary(dict=> dict.Key, dict=> dict.Value);
var model = new InvoiceDetailsModel var model = new InvoiceDetailsModel
{ {
@@ -139,7 +146,6 @@ namespace BTCPayServer.Controllers
TypedMetadata = invoice.Metadata, TypedMetadata = invoice.Metadata,
StatusException = invoice.ExceptionStatus, StatusException = invoice.ExceptionStatus,
Events = invoice.Events, Events = invoice.Events,
PosData = posData,
Metadata = metaData, Metadata = metaData,
AdditionalData = additionalData, AdditionalData = additionalData,
Archived = invoice.Archived, Archived = invoice.Archived,
@@ -236,9 +242,7 @@ namespace BTCPayServer.Controllers
vm.Amount = payments.Sum(p => p!.Paid); vm.Amount = payments.Sum(p => p!.Paid);
vm.Payments = receipt.ShowPayments is false ? null : payments; vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = receiptData is null vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
? new Dictionary<string, object>()
: PosDataParser.ParsePosData(receiptData.ToString());
return View(vm); return View(vm);
} }
@@ -1239,30 +1243,20 @@ namespace BTCPayServer.Controllers
public class PosDataParser public class PosDataParser
{ {
public static Dictionary<string, object> ParsePosData(string posData) public static Dictionary<string, object> ParsePosData(JToken? posData)
{ {
var result = new Dictionary<string, object>(); var result = new Dictionary<string, object>();
if (string.IsNullOrEmpty(posData)) if (posData is JObject jobj)
{ {
return result; foreach (var item in jobj)
}
try
{
var jObject = JObject.Parse(posData);
foreach (var item in jObject)
{ {
ParsePosDataItem(item, ref result); ParsePosDataItem(item, ref result);
} }
} }
catch
{
result.TryAdd(string.Empty, posData);
}
return result; return result;
} }
public static void ParsePosDataItem(KeyValuePair<string, JToken?> item, ref Dictionary<string, object> result) static void ParsePosDataItem(KeyValuePair<string, JToken?> item, ref Dictionary<string, object> result)
{ {
switch (item.Value?.Type) switch (item.Value?.Type)
{ {
@@ -1270,12 +1264,12 @@ namespace BTCPayServer.Controllers
var items = item.Value.AsEnumerable().ToList(); var items = item.Value.AsEnumerable().ToList();
for (var i = 0; i < items.Count; i++) for (var i = 0; i < items.Count; i++)
{ {
result.TryAdd($"{item.Key}[{i}]", ParsePosData(items[i].ToString())); result.TryAdd($"{item.Key}[{i}]", ParsePosData(items[i]));
} }
break; break;
case JTokenType.Object: case JTokenType.Object:
result.TryAdd(item.Key, ParsePosData(item.Value.ToString())); result.TryAdd(item.Key, ParsePosData(item.Value));
break; break;
case null: case null:
break; break;

View File

@@ -30,6 +30,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using NBitcoin; using NBitcoin;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json.Linq;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData; using StoreData = BTCPayServer.Data.StoreData;
@@ -117,7 +118,7 @@ namespace BTCPayServer.Controllers
throw new BitpayHttpException(400, "The expirationTime is set too soon"); throw new BitpayHttpException(400, "The expirationTime is set too soon");
} }
entity.Metadata.OrderId = invoice.OrderId; entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosData = invoice.PosData; entity.Metadata.PosDataLegacy = invoice.PosData;
entity.ServerUrl = serverUrl; entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications; entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications; entity.ExtendedNotifications = invoice.ExtendedNotifications;

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using NBitcoin;
using Newtonsoft.Json.Linq;
namespace BTCPayServer
{
public static class HasAdditionalDataExtensions
{
public static T GetAdditionalData<T>(this IHasAdditionalData o, string propName)
{
if (o.AdditionalData == null || !(o.AdditionalData.TryGetValue(propName, out var jt) is true))
return default;
if (jt.Type == JTokenType.Null)
return default;
if (typeof(T) == typeof(string))
{
return (T)(object)jt.ToString();
}
try
{
return jt.Value<T>();
}
catch (Exception)
{
return default;
}
}
public static void SetAdditionalData<T>(this IHasAdditionalData o, string propName, T value)
{
JToken data;
if (typeof(T) == typeof(string) && value is string v)
{
data = new JValue(v);
o.AdditionalData ??= new Dictionary<string, JToken>();
o.AdditionalData.AddOrReplace(propName, data);
return;
}
if (value is null)
{
o.AdditionalData?.Remove(propName);
}
else
{
try
{
if (value is string s)
{
data = JToken.Parse(s);
}
else
{
data = JToken.FromObject(value);
}
}
catch (Exception)
{
data = JToken.FromObject(value);
}
o.AdditionalData ??= new Dictionary<string, JToken>();
o.AdditionalData.AddOrReplace(propName, data);
}
}
}
public interface IHasAdditionalData
{
IDictionary<string, JToken> AdditionalData { get; set; }
}
}

View File

@@ -124,7 +124,6 @@ namespace BTCPayServer.Models.InvoicingModels
public DateTimeOffset MonitoringDate { get; internal set; } public DateTimeOffset MonitoringDate { get; internal set; }
public List<Data.InvoiceEventData> Events { get; internal set; } public List<Data.InvoiceEventData> Events { get; internal set; }
public string NotificationEmail { get; internal set; } public string NotificationEmail { get; internal set; }
public Dictionary<string, object> PosData { get; set; }
public Dictionary<string, object> Metadata { get; set; } public Dictionary<string, object> Metadata { get; set; }
public Dictionary<string, object> AdditionalData { get; set; } public Dictionary<string, object> AdditionalData { get; set; }
public List<PaymentEntity> Payments { get; set; } public List<PaymentEntity> Payments { get; set; }

View File

@@ -151,6 +151,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{ {
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType }); return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
} }
var jposData = TryParseJObject(posData);
string title; string title;
decimal? price; decimal? price;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null; Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
@@ -191,10 +192,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
price = amount; price = amount;
title = settings.Title; title = settings.Title;
//if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items //if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
if (!string.IsNullOrEmpty(posData) && currentView == PosViewType.Cart && if (currentView == PosViewType.Cart &&
AppService.TryParsePosCartItems(posData, out var cartItems)) AppService.TryParsePosCartItems(jposData, out var cartItems))
{ {
var choices = _appService.GetPOSItems(settings.Template, settings.Currency); var choices = _appService.GetPOSItems(settings.Template, settings.Currency);
var expectedMinimumAmount = 0m; var expectedMinimumAmount = 0m;
@@ -257,7 +257,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return View("PostRedirect", vm); return View("PostRedirect", vm);
} }
formResponseJObject = JObject.Parse(formResponse); formResponseJObject = TryParseJObject(formResponse) ?? new JObject();
var form = Form.Parse(formData.Config); var form = Form.Parse(formData.Config);
form.SetValues(formResponseJObject); form.SetValues(formResponseJObject);
if (!FormDataService.Validate(form, ModelState)) if (!FormDataService.Validate(form, ModelState))
@@ -269,7 +269,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
formResponseJObject = form.GetValues(); formResponseJObject = form.GetValues();
break; break;
} }
try try
{ {
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
@@ -287,7 +286,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
: Request.GetAbsoluteUri(Url.Action(nameof(ViewPointOfSale), "UIPointOfSale", new { appId, viewType })), : Request.GetAbsoluteUri(Url.Action(nameof(ViewPointOfSale), "UIPointOfSale", new { appId, viewType })),
FullNotifications = true, FullNotifications = true,
ExtendedNotifications = true, ExtendedNotifications = true,
PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically, RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods, SupportedTransactionCurrencies = paymentMethods,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
@@ -298,23 +296,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
cancellationToken, entity => cancellationToken, entity =>
{ {
entity.Metadata.OrderUrl = Request.GetDisplayUrl(); entity.Metadata.OrderUrl = Request.GetDisplayUrl();
entity.Metadata.PosData = jposData;
if (formResponseJObject is null) return; if (formResponseJObject is null) return;
var meta = entity.Metadata.ToJObject(); var meta = entity.Metadata.ToJObject();
if (formResponseJObject.ContainsKey("posData") && meta.TryGetValue("posData", out var posDataValue) && posDataValue.Type == JTokenType.String) meta.Merge(formResponseJObject);
{ entity.Metadata = InvoiceMetadata.FromJObject(meta);
try
{
meta["posData"] = JObject.Parse(posDataValue.Value<string>());
}
catch (Exception e)
{
// ignored as we don't want to break the invoice creation
}
}
formResponseJObject.Merge(meta);
entity.Metadata = InvoiceMetadata.FromJObject(formResponseJObject);
}); });
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Data.Id }); return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Data.Id });
} }
@@ -330,6 +316,18 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
} }
private JObject TryParseJObject(string posData)
{
try
{
return JObject.Parse(posData);
}
catch
{
}
return null;
}
[HttpPost("/apps/{appId}/pos/form/{viewType?}")] [HttpPost("/apps/{appId}/pos/form/{viewType?}")]
public async Task<IActionResult> POSForm(string appId, PosViewType? viewType = null) public async Task<IActionResult> POSForm(string appId, PosViewType? viewType = null)
{ {

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@@ -18,6 +19,7 @@ using Ganss.XSS;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBitpayClient;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using YamlDotNet.Core; using YamlDotNet.Core;
@@ -250,7 +252,7 @@ namespace BTCPayServer.Services.Apps
var itemCount = paidInvoices var itemCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.Currency, StringComparison.OrdinalIgnoreCase) && ( .Where(entity => entity.Currency.Equals(settings.Currency, StringComparison.OrdinalIgnoreCase) && (
// The POS data is present for the cart view, where multiple items can be bought // The POS data is present for the cart view, where multiple items can be bought
!string.IsNullOrEmpty(entity.Metadata.PosData) || entity.Metadata.PosData != null ||
// The item code should be present for all types other than the cart and keypad // The item code should be present for all types other than the cart and keypad
!string.IsNullOrEmpty(entity.Metadata.ItemCode) !string.IsNullOrEmpty(entity.Metadata.ItemCode)
)) ))
@@ -335,10 +337,10 @@ namespace BTCPayServer.Services.Apps
{ {
return (res, e) => return (res, e) =>
{ {
if (!string.IsNullOrEmpty(e.Metadata.PosData)) if (e.Metadata.PosData != null)
{ {
// flatten single items from POS data // flatten single items from POS data
var data = JsonConvert.DeserializeObject<PosAppData>(e.Metadata.PosData); var data = e.Metadata.PosData.ToObject<PosAppData>();
if (data is not { Cart.Length: > 0 }) if (data is not { Cart.Length: > 0 })
return res; return res;
foreach (var lineItem in data.Cart) foreach (var lineItem in data.Cart)
@@ -777,18 +779,33 @@ namespace BTCPayServer.Services.Apps
return false; return false;
} }
} }
#nullable enable
public static bool TryParsePosCartItems(string posData, out Dictionary<string, int> cartItems) public static bool TryParsePosCartItems(JObject? posData, [MaybeNullWhen(false)] out Dictionary<string, int> cartItems)
{ {
cartItems = null; cartItems = null;
if (!TryParseJson(posData, out var posDataObj) || if (posData is null)
!posDataObj.TryGetValue("cart", out var cartObject))
return false; return false;
cartItems = cartObject.Select(token => (JObject)token) if (!posData.TryGetValue("cart", out var cartObject))
.ToDictionary(o => o.GetValue("id", StringComparison.InvariantCulture)?.ToString(), return false;
o => int.Parse(o.GetValue("count", StringComparison.InvariantCulture)?.ToString() ?? string.Empty, CultureInfo.InvariantCulture)); if (cartObject is null)
return false;
cartItems = new();
foreach (var o in cartObject.OfType<JObject>())
{
var id = o.GetValue("id", StringComparison.InvariantCulture)?.ToString();
if (id != null)
{
var countStr = o.GetValue("count", StringComparison.InvariantCulture)?.ToString() ?? string.Empty;
if (int.TryParse(countStr, out var count))
{
cartItems.TryAdd(id, count);
}
}
}
return true; return true;
} }
#nullable restore
} }
public class ItemStats public class ItemStats

View File

@@ -30,7 +30,7 @@ namespace BTCPayServer.Services.Invoices
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; } [JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
} }
} }
public class InvoiceMetadata public class InvoiceMetadata : IHasAdditionalData
{ {
public static readonly JsonSerializer MetadataSerializer; public static readonly JsonSerializer MetadataSerializer;
static InvoiceMetadata() static InvoiceMetadata()
@@ -45,165 +45,167 @@ namespace BTCPayServer.Services.Invoices
[JsonIgnore] [JsonIgnore]
public string OrderId public string OrderId
{ {
get => GetMetadata<string>("orderId"); get => this.GetAdditionalData<string>("orderId");
set => SetMetadata("orderId", value); set => this.SetAdditionalData("orderId", value);
} }
[JsonIgnore] [JsonIgnore]
public string OrderUrl public string OrderUrl
{ {
get => GetMetadata<string>("orderUrl"); get => this.GetAdditionalData<string>("orderUrl");
set => SetMetadata("orderUrl", value); set => this.SetAdditionalData("orderUrl", value);
} }
[JsonIgnore] [JsonIgnore]
public string PaymentRequestId public string PaymentRequestId
{ {
get => GetMetadata<string>("paymentRequestId"); get => this.GetAdditionalData<string>("paymentRequestId");
set => SetMetadata("paymentRequestId", value); set => this.SetAdditionalData("paymentRequestId", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerName public string BuyerName
{ {
get => GetMetadata<string>("buyerName"); get => this.GetAdditionalData<string>("buyerName");
set => SetMetadata("buyerName", value); set => this.SetAdditionalData("buyerName", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerEmail public string BuyerEmail
{ {
get => GetMetadata<string>("buyerEmail"); get => this.GetAdditionalData<string>("buyerEmail");
set => SetMetadata("buyerEmail", value); set => this.SetAdditionalData("buyerEmail", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerCountry public string BuyerCountry
{ {
get => GetMetadata<string>("buyerCountry"); get => this.GetAdditionalData<string>("buyerCountry");
set => SetMetadata("buyerCountry", value); set => this.SetAdditionalData("buyerCountry", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerZip public string BuyerZip
{ {
get => GetMetadata<string>("buyerZip"); get => this.GetAdditionalData<string>("buyerZip");
set => SetMetadata("buyerZip", value); set => this.SetAdditionalData("buyerZip", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerState public string BuyerState
{ {
get => GetMetadata<string>("buyerState"); get => this.GetAdditionalData<string>("buyerState");
set => SetMetadata("buyerState", value); set => this.SetAdditionalData("buyerState", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerCity public string BuyerCity
{ {
get => GetMetadata<string>("buyerCity"); get => this.GetAdditionalData<string>("buyerCity");
set => SetMetadata("buyerCity", value); set => this.SetAdditionalData("buyerCity", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerAddress2 public string BuyerAddress2
{ {
get => GetMetadata<string>("buyerAddress2"); get => this.GetAdditionalData<string>("buyerAddress2");
set => SetMetadata("buyerAddress2", value); set => this.SetAdditionalData("buyerAddress2", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerAddress1 public string BuyerAddress1
{ {
get => GetMetadata<string>("buyerAddress1"); get => this.GetAdditionalData<string>("buyerAddress1");
set => SetMetadata("buyerAddress1", value); set => this.SetAdditionalData("buyerAddress1", value);
} }
[JsonIgnore] [JsonIgnore]
public string BuyerPhone public string BuyerPhone
{ {
get => GetMetadata<string>("buyerPhone"); get => this.GetAdditionalData<string>("buyerPhone");
set => SetMetadata("buyerPhone", value); set => this.SetAdditionalData("buyerPhone", value);
} }
[JsonIgnore] [JsonIgnore]
public string ItemDesc public string ItemDesc
{ {
get => GetMetadata<string>("itemDesc"); get => this.GetAdditionalData<string>("itemDesc");
set => SetMetadata("itemDesc", value); set => this.SetAdditionalData("itemDesc", value);
} }
[JsonIgnore] [JsonIgnore]
public string ItemCode public string ItemCode
{ {
get => GetMetadata<string>("itemCode"); get => this.GetAdditionalData<string>("itemCode");
set => SetMetadata("itemCode", value); set => this.SetAdditionalData("itemCode", value);
} }
[JsonIgnore] [JsonIgnore]
public bool? Physical public bool? Physical
{ {
get => GetMetadata<bool?>("physical"); get => this.GetAdditionalData<bool?>("physical");
set => SetMetadata("physical", value); set => this.SetAdditionalData("physical", value);
} }
[JsonIgnore] [JsonIgnore]
public decimal? TaxIncluded public decimal? TaxIncluded
{ {
get => GetMetadata<decimal?>("taxIncluded"); get => this.GetAdditionalData<decimal?>("taxIncluded");
set => SetMetadata("taxIncluded", value); set => this.SetAdditionalData("taxIncluded", value);
} }
/// <summary>
/// posData is a field that may be treated differently for presentation and in some legacy API
/// Before, it was a string field which could contain some JSON data inside.
/// For making it easier to query on the DB, and for logic using PosData in the code, we decided to
/// parse it as a JObject.
///
/// This property will return the posData as a JObject, even if it's a Json string inside.
/// </summary>
[JsonIgnore] [JsonIgnore]
public string PosData public JObject PosData
{ {
get => GetMetadata<string>("posData"); get
set => SetMetadata("posData", value); {
if (AdditionalData == null || !(AdditionalData.TryGetValue("posData", out var jt) is true))
return default;
if (jt.Type == JTokenType.Null)
return default;
if (jt.Type == JTokenType.String)
try
{
return JObject.Parse(jt.Value<string>());
}
catch
{
return null;
}
if (jt.Type == JTokenType.Object)
return (JObject)jt;
return null;
}
set
{
this.SetAdditionalData<JObject>("posData", value);
}
}
/// <summary>
/// See comments on <see cref="PosData"/>
/// </summary>
[JsonIgnore]
public string PosDataLegacy
{
get
{
return this.GetAdditionalData<string>("posData");
}
set
{
if (value != null)
{
try
{
PosData = JObject.Parse(value);
return;
}
catch
{
}
}
this.SetAdditionalData<string>("posData", value);
}
} }
[JsonExtensionData] [JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } public IDictionary<string, JToken> AdditionalData { get; set; }
public T GetMetadata<T>(string propName)
{
if (AdditionalData == null || !(AdditionalData.TryGetValue(propName, out var jt) is true))
return default;
if (jt.Type == JTokenType.Null)
return default;
if (typeof(T) == typeof(string))
{
return (T)(object)jt.ToString();
}
try
{
return jt.Value<T>();
}
catch (Exception)
{
return default;
}
}
public void SetMetadata<T>(string propName, T value)
{
JToken data;
if (typeof(T) == typeof(string) && value is string v)
{
data = new JValue(v);
AdditionalData ??= new Dictionary<string, JToken>();
AdditionalData.AddOrReplace(propName, data);
return;
}
if (value is null)
{
AdditionalData?.Remove(propName);
}
else
{
try
{
if (value is string s)
{
data = JToken.Parse(s);
}
else
{
data = JToken.FromObject(value);
}
}
catch (Exception)
{
data = JToken.FromObject(value);
}
AdditionalData ??= new Dictionary<string, JToken>();
AdditionalData.AddOrReplace(propName, data);
}
}
public static InvoiceMetadata FromJObject(JObject jObject) public static InvoiceMetadata FromJObject(JObject jObject)
{ {
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer); return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
@@ -214,7 +216,7 @@ namespace BTCPayServer.Services.Invoices
} }
} }
public class InvoiceEntity public class InvoiceEntity : IHasAdditionalData
{ {
class BuyerInformation class BuyerInformation
{ {
@@ -302,11 +304,35 @@ namespace BTCPayServer.Services.Invoices
.Select(t => t.Substring(prefix.Length)).ToArray(); .Select(t => t.Substring(prefix.Length)).ToArray();
} }
[Obsolete("Use GetDerivationStrategies instead")]
public string DerivationStrategy { get; set; }
[Obsolete("Use GetPaymentMethodFactories() instead")] [Obsolete("Use GetPaymentMethodFactories() instead")]
public string DerivationStrategies { get; set; } [JsonIgnore]
public JObject DerivationStrategies
{
get
{
if (AdditionalData is null || AdditionalData.TryGetValue("derivationStrategies", out var v) is not true)
{
if (AdditionalData is null || AdditionalData.TryGetValue("derivationStrategy", out v) is not true || Networks.BTC is null)
return null;
// This code is very unlikely called. "derivationStrategy" is an old property that was present in 2018.
// And this property is only read for unexpired invoices with lazy payments (Feature unavailable then)
var settings = DerivationSchemeSettings.Parse(v.ToString(), Networks.BTC);
settings.AccountOriginal = v.ToString();
settings.Source = "ManualDerivationScheme";
return JObject.Parse(settings.ToJson());
}
if (v.Type == JTokenType.String)
return JObject.Parse(v.Value<string>());
if (v.Type == JTokenType.Object)
return (JObject)v;
return null;
}
set
{
this.SetAdditionalData("derivationStrategies", value);
this.SetAdditionalData<string>("derivationStrategy", null);
}
}
public IEnumerable<T> GetSupportedPaymentMethod<T>(PaymentMethodId paymentMethodId) where T : ISupportedPaymentMethod public IEnumerable<T> GetSupportedPaymentMethod<T>(PaymentMethodId paymentMethodId) where T : ISupportedPaymentMethod
{ {
return return
@@ -321,11 +347,9 @@ namespace BTCPayServer.Services.Invoices
public IEnumerable<ISupportedPaymentMethod> GetSupportedPaymentMethod() public IEnumerable<ISupportedPaymentMethod> GetSupportedPaymentMethod()
{ {
#pragma warning disable CS0618 #pragma warning disable CS0618
bool btcReturned = false; if (DerivationStrategies != null)
if (!string.IsNullOrEmpty(DerivationStrategies))
{ {
JObject strategies = JObject.Parse(DerivationStrategies); foreach (var strat in DerivationStrategies.Properties())
foreach (var strat in strategies.Properties())
{ {
if (!PaymentMethodId.TryParse(strat.Name, out var paymentMethodId)) if (!PaymentMethodId.TryParse(strat.Name, out var paymentMethodId))
{ {
@@ -334,20 +358,10 @@ namespace BTCPayServer.Services.Invoices
var network = Networks.GetNetwork<BTCPayNetworkBase>(paymentMethodId.CryptoCode); var network = Networks.GetNetwork<BTCPayNetworkBase>(paymentMethodId.CryptoCode);
if (network != null) if (network != null)
{ {
if (network == Networks.BTC && paymentMethodId.PaymentType == PaymentTypes.BTCLike)
btcReturned = true;
yield return paymentMethodId.PaymentType.DeserializeSupportedPaymentMethod(network, strat.Value); yield return paymentMethodId.PaymentType.DeserializeSupportedPaymentMethod(network, strat.Value);
} }
} }
} }
if (!btcReturned && !string.IsNullOrEmpty(DerivationStrategy))
{
if (Networks.BTC != null)
{
yield return BTCPayServer.DerivationSchemeSettings.Parse(DerivationStrategy, Networks.BTC);
}
}
#pragma warning restore CS0618 #pragma warning restore CS0618
} }
@@ -358,10 +372,8 @@ namespace BTCPayServer.Services.Invoices
{ {
obj.Add(strat.PaymentId.ToString(), PaymentMethodExtensions.Serialize(strat)); obj.Add(strat.PaymentId.ToString(), PaymentMethodExtensions.Serialize(strat));
#pragma warning disable CS0618 #pragma warning disable CS0618
// This field should eventually disappear
DerivationStrategy = null;
} }
DerivationStrategies = JsonConvert.SerializeObject(obj); DerivationStrategies = obj;
#pragma warning restore CS0618 #pragma warning restore CS0618
} }
@@ -463,7 +475,7 @@ namespace BTCPayServer.Services.Invoices
Id = Id, Id = Id,
StoreId = StoreId, StoreId = StoreId,
OrderId = Metadata.OrderId, OrderId = Metadata.OrderId,
PosData = Metadata.PosData, PosData = Metadata.PosDataLegacy,
CurrentTime = DateTimeOffset.UtcNow, CurrentTime = DateTimeOffset.UtcNow,
InvoiceTime = InvoiceTime, InvoiceTime = InvoiceTime,
ExpirationTime = ExpirationTime, ExpirationTime = ExpirationTime,
@@ -710,7 +722,7 @@ namespace BTCPayServer.Services.Invoices
token is JValue val && token is JValue val &&
val.Type == JTokenType.String) val.Type == JTokenType.String)
{ {
wellknown.PosData = val.Value<string>(); wellknown.PosDataLegacy = val.Value<string>();
} }
if (AdditionalData.TryGetValue("orderId", out var token2) && if (AdditionalData.TryGetValue("orderId", out var token2) &&
token2 is JValue val2 && token2 is JValue val2 &&

View File

@@ -253,39 +253,32 @@
</table> </table>
</div> </div>
<div class="d-flex flex-column gap-5"> <div class="d-flex flex-column gap-5">
@if (Model.PosData.Any()) <div>
{ <h3 class="mb-3">Product Information</h3>
<div> <table class="table mb-0">
<h3 class="mb-3">Product Information</h3> @if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
<table class="table mb-0"> {
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
{
<tr>
<th class="fw-semibold">Item code</th>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
<tr>
<th class="fw-semibold">Item Description</th>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
}
<tr> <tr>
<th class="fw-semibold">Price</th> <th class="fw-semibold">Item code</th>
<td>@Model.Fiat</td> <td>@Model.TypedMetadata.ItemCode</td>
</tr> </tr>
@if (Model.TaxIncluded is not null) }
{ @if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
<tr> {
<th class="fw-semibold">Tax Included</th> <tr>
<td>@Model.TaxIncluded</td> <th class="fw-semibold">Item Description</th>
</tr> <td>@Model.TypedMetadata.ItemDesc</td>
} </tr>
</table> }
</div> @if (Model.TaxIncluded is not null)
} {
<tr>
<th class="fw-semibold">Tax Included</th>
<td>@Model.TaxIncluded</td>
</tr>
}
</table>
</div>
@if (Model.TypedMetadata.BuyerName is not null || @if (Model.TypedMetadata.BuyerName is not null ||
Model.TypedMetadata.BuyerEmail is not null || Model.TypedMetadata.BuyerEmail is not null ||
Model.TypedMetadata.BuyerPhone is not null || Model.TypedMetadata.BuyerPhone is not null ||