diff --git a/BTCPayServer.Client/BTCPayServerClient.Invoices.cs b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs new file mode 100644 index 000000000..7710ea118 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +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> GetInvoices(string storeId, bool includeArchived = false, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices", + new Dictionary() {{nameof(includeArchived), includeArchived}}), token); + return await HandleResponse>(response); + } + + public virtual async Task GetInvoice(string storeId, string invoiceId, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}"), token); + return await HandleResponse(response); + } + + public virtual async Task ArchiveInvoice(string storeId, string invoiceId, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}", + method: HttpMethod.Delete), token); + await HandleResponse(response); + } + + public virtual async Task CreateInvoice(string storeId, + CreateInvoiceRequest request, CancellationToken token = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices", bodyPayload: request, + method: HttpMethod.Post), token); + return await HandleResponse(response); + } + + public virtual async Task MarkInvoiceStatus(string storeId, string invoiceId, + MarkInvoiceStatusRequest request, CancellationToken token = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (request.Status!= InvoiceStatus.Complete && request.Status!= InvoiceStatus.Invalid) + throw new ArgumentOutOfRangeException(nameof(request.Status), "Status can only be Invalid or Complete"); + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/status", bodyPayload: request, + method: HttpMethod.Post), token); + return await HandleResponse(response); + } + + public virtual async Task UnarchiveInvoice(string storeId, string invoiceId, CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive", + method: HttpMethod.Post), token); + return await HandleResponse(response); + } + } +} diff --git a/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs b/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs index feeea0151..eff1f46f1 100644 --- a/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs +++ b/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs @@ -10,10 +10,13 @@ namespace BTCPayServer.Client public partial class BTCPayServerClient { public virtual async Task> GetPaymentRequests(string storeId, + bool includeArchived = false, CancellationToken token = default) { var response = - await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payment-requests"), token); + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-requests", + new Dictionary() {{nameof(includeArchived), includeArchived}}), token); return await HandleResponse>(response); } diff --git a/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs b/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs index 621fb783a..b392655d4 100644 --- a/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs +++ b/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs @@ -4,13 +4,38 @@ using Newtonsoft.Json; namespace BTCPayServer.Client.JsonConverters { - public class TimeSpanJsonConverter : JsonConverter + public abstract class TimeSpanJsonConverter : JsonConverter { + public class Seconds : TimeSpanJsonConverter + { + protected override long ToLong(TimeSpan value) + { + return (long)value.TotalSeconds; + } + + protected override TimeSpan ToTimespan(long value) + { + return TimeSpan.FromSeconds(value); + } + } + public class Minutes : TimeSpanJsonConverter + { + protected override long ToLong(TimeSpan value) + { + return (long)value.TotalMinutes; + } + protected override TimeSpan ToTimespan(long value) + { + return TimeSpan.FromMinutes(value); + } + } public override bool CanConvert(Type objectType) { return objectType == typeof(TimeSpan) || objectType == typeof(TimeSpan?); } + protected abstract TimeSpan ToTimespan(long value); + protected abstract long ToLong(TimeSpan value); public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { try @@ -24,11 +49,11 @@ namespace BTCPayServer.Client.JsonConverters } if (reader.TokenType != JsonToken.Integer) throw new JsonObjectException("Invalid timespan, expected integer", reader); - return TimeSpan.FromSeconds((long)reader.Value); + return ToTimespan((long)reader.Value); } catch { - throw new JsonObjectException("Invalid locktime", reader); + throw new JsonObjectException("Invalid timespan", reader); } } @@ -36,7 +61,7 @@ namespace BTCPayServer.Client.JsonConverters { if (value is TimeSpan s) { - writer.WriteValue((long)s.TotalSeconds); + writer.WriteValue(ToLong(s)); } } } diff --git a/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs b/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs new file mode 100644 index 000000000..f34e15c86 --- /dev/null +++ b/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Client.Models +{ + public class AddCustomerEmailRequest + { + public string Email { get; set; } + } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/CreateInvoiceRequest.cs b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs new file mode 100644 index 000000000..c37f6a1b7 --- /dev/null +++ b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs @@ -0,0 +1,35 @@ +using System; +using BTCPayServer.Client.JsonConverters; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Client.Models +{ + public class CreateInvoiceRequest + { + [JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))] + public decimal Amount { get; set; } + public string Currency { get; set; } + public JObject Metadata { get; set; } + public CheckoutOptions Checkout { get; set; } = new CheckoutOptions(); + + public class CheckoutOptions + { + [JsonConverter(typeof(StringEnumConverter))] + public SpeedPolicy? SpeedPolicy { get; set; } + + public string[] PaymentMethods { get; set; } + + [JsonConverter(typeof(TimeSpanJsonConverter.Minutes))] + [JsonProperty("expirationMinutes")] + public TimeSpan? Expiration { get; set; } + [JsonConverter(typeof(TimeSpanJsonConverter.Minutes))] + [JsonProperty("monitoringMinutes")] + public TimeSpan? Monitoring { get; set; } + + public double? PaymentTolerance { get; set; } + } + } +} diff --git a/BTCPayServer.Client/Models/CreateLightningInvoiceRequest.cs b/BTCPayServer.Client/Models/CreateLightningInvoiceRequest.cs index 4282b422c..ba38843fa 100644 --- a/BTCPayServer.Client/Models/CreateLightningInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/CreateLightningInvoiceRequest.cs @@ -20,7 +20,7 @@ namespace BTCPayServer.Client.Models [JsonConverter(typeof(LightMoneyJsonConverter))] public LightMoney Amount { get; set; } public string Description { get; set; } - [JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter))] + [JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter.Seconds))] public TimeSpan Expiry { get; set; } public bool PrivateRouteHints { get; set; } diff --git a/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs b/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs index a314573ad..6cce582ca 100644 --- a/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs +++ b/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs @@ -11,7 +11,7 @@ namespace BTCPayServer.Client.Models [JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))] public decimal Amount { get; set; } public string Currency { get; set; } - [JsonConverter(typeof(TimeSpanJsonConverter))] + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] public TimeSpan? Period { get; set; } [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] public DateTimeOffset? ExpiresAt { get; set; } diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs new file mode 100644 index 000000000..f31644f94 --- /dev/null +++ b/BTCPayServer.Client/Models/InvoiceData.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BTCPayServer.Client.Models +{ + public class InvoiceData : CreateInvoiceRequest + { + public string Id { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public InvoiceStatus Status { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public InvoiceExceptionStatus AdditionalStatus { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset MonitoringExpiration { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset ExpirationTime { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset CreatedTime { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/InvoiceExceptionStatus.cs b/BTCPayServer.Client/Models/InvoiceExceptionStatus.cs new file mode 100644 index 000000000..e526fde3a --- /dev/null +++ b/BTCPayServer.Client/Models/InvoiceExceptionStatus.cs @@ -0,0 +1,12 @@ +namespace BTCPayServer.Client.Models +{ + public enum InvoiceExceptionStatus + { + None, + PaidLate, + PaidPartial, + Marked, + Invalid, + PaidOver + } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/InvoiceStatus.cs b/BTCPayServer.Client/Models/InvoiceStatus.cs new file mode 100644 index 000000000..a3d866acd --- /dev/null +++ b/BTCPayServer.Client/Models/InvoiceStatus.cs @@ -0,0 +1,12 @@ +namespace BTCPayServer.Client.Models +{ + public enum InvoiceStatus + { + New, + Paid, + Expired, + Invalid, + Complete, + Confirmed + } +} \ No newline at end of file diff --git a/BTCPayServer.Client/Models/MarkInvoiceStatusRequest.cs b/BTCPayServer.Client/Models/MarkInvoiceStatusRequest.cs new file mode 100644 index 000000000..a06919dad --- /dev/null +++ b/BTCPayServer.Client/Models/MarkInvoiceStatusRequest.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BTCPayServer.Client.Models +{ + public class MarkInvoiceStatusRequest + { + [JsonConverter(typeof(StringEnumConverter))] + public InvoiceStatus Status { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/PullPaymentBaseData.cs b/BTCPayServer.Client/Models/PullPaymentBaseData.cs index d95b60e06..371bdee9d 100644 --- a/BTCPayServer.Client/Models/PullPaymentBaseData.cs +++ b/BTCPayServer.Client/Models/PullPaymentBaseData.cs @@ -16,7 +16,7 @@ namespace BTCPayServer.Client.Models public string Currency { get; set; } [JsonConverter(typeof(NumericStringJsonConverter))] public decimal Amount { get; set; } - [JsonConverter(typeof(TimeSpanJsonConverter))] + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] public TimeSpan? Period { get; set; } public bool Archived { get; set; } public string ViewLink { get; set; } diff --git a/BTCPayServer.Client/Models/StoreBaseData.cs b/BTCPayServer.Client/Models/StoreBaseData.cs index 33cb6c00f..61e8ed747 100644 --- a/BTCPayServer.Client/Models/StoreBaseData.cs +++ b/BTCPayServer.Client/Models/StoreBaseData.cs @@ -16,11 +16,11 @@ namespace BTCPayServer.Client.Models public string Website { get; set; } - [JsonConverter(typeof(TimeSpanJsonConverter))] + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15); - [JsonConverter(typeof(TimeSpanJsonConverter))] + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60); diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs index 94c7f19f4..9d939cea3 100644 --- a/BTCPayServer.Client/Permissions.cs +++ b/BTCPayServer.Client/Permissions.cs @@ -14,6 +14,7 @@ namespace BTCPayServer.Client public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings"; public const string CanModifyStoreSettingsUnscoped = "btcpay.store.canmodifystoresettings:"; public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings"; + public const string CanViewInvoices = "btcpay.store.canviewinvoices"; public const string CanCreateInvoice = "btcpay.store.cancreateinvoice"; public const string CanViewPaymentRequests = "btcpay.store.canviewpaymentrequests"; public const string CanModifyPaymentRequests = "btcpay.store.canmodifypaymentrequests"; @@ -26,6 +27,7 @@ namespace BTCPayServer.Client { get { + yield return CanViewInvoices; yield return CanCreateInvoice; yield return CanModifyServerSettings; yield return CanModifyStoreSettings; @@ -153,6 +155,8 @@ namespace BTCPayServer.Client return true; switch (subpolicy) { + case Policies.CanViewInvoices when this.Policy == Policies.CanModifyStoreSettings: + case Policies.CanViewInvoices when this.Policy == Policies.CanViewStoreSettings: case Policies.CanViewStoreSettings when this.Policy == Policies.CanModifyStoreSettings: case Policies.CanCreateInvoice when this.Policy == Policies.CanModifyStoreSettings: case Policies.CanViewProfile when this.Policy == Policies.CanModifyProfile: diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs index 8d4cc2769..3f361a90f 100644 --- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs +++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs @@ -877,7 +877,7 @@ normal: InvoiceEntity invoiceEntity = new InvoiceEntity(); invoiceEntity.Networks = networkProvider; invoiceEntity.Payments = new System.Collections.Generic.List(); - invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 }; + invoiceEntity.Price = 100; PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); paymentMethods.Add(new PaymentMethod() { Network = networkBTC, CryptoCode = "BTC", Rate = 10513.44m, } .SetPaymentMethodDetails( diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs index 4150027aa..dd4cb8972 100644 --- a/BTCPayServer.Tests/ApiKeysTests.cs +++ b/BTCPayServer.Tests/ApiKeysTests.cs @@ -89,7 +89,7 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("AddApiKey")).Click(); s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).Click(); //there should be a store already by default in the dropdown - var dropdown = s.Driver.FindElement(By.Name("PermissionValues[2].SpecificStores[0]")); + var dropdown = s.Driver.FindElement(By.Name("PermissionValues[3].SpecificStores[0]")); var option = dropdown.FindElement(By.TagName("option")); var storeId = option.GetAttribute("value"); option.Click(); diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index 76ffc3d2d..f7d08c634 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -17,9 +17,6 @@ $(DefineConstants);SHORT_TIMEOUT - - $(DefineConstants);ALTCOINS - diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 1262e55e6..050a8a002 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -10,6 +10,7 @@ using BTCPayServer.Controllers; using BTCPayServer.Events; using BTCPayServer.JsonConverters; using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -17,6 +18,7 @@ using NBitcoin; using NBitpayClient; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NUglify.Helpers; using Xunit; using Xunit.Abstractions; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; @@ -732,6 +734,147 @@ namespace BTCPayServer.Tests } } + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task InvoiceLegacyTests() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.GrantAccessAsync(); + user.RegisterDerivationScheme("BTC"); + var client = await user.CreateClient(Policies.Unrestricted); + var oldBitpay = user.BitPay; + + Logs.Tester.LogInformation("Let's create an invoice with bitpay API"); + var oldInvoice = await oldBitpay.CreateInvoiceAsync(new Invoice() + { + Currency = "BTC", + Price = 1000.19392922m, + BuyerAddress1 = "blah", + Buyer = new Buyer() + { + Address2 = "blah2" + }, + ItemCode = "code", + ItemDesc = "desc", + OrderId = "orderId", + PosData = "posData" + }); + + async Task AssertInvoiceMetadata() + { + Logs.Tester.LogInformation("Let's check if we can get invoice in the new format with the metadata"); + var newInvoice = await client.GetInvoice(user.StoreId, oldInvoice.Id); + Assert.Equal("posData", newInvoice.Metadata["posData"].Value()); + Assert.Equal("code", newInvoice.Metadata["itemCode"].Value()); + Assert.Equal("desc", newInvoice.Metadata["itemDesc"].Value()); + Assert.Equal("orderId", newInvoice.Metadata["orderId"].Value()); + Assert.False(newInvoice.Metadata["physical"].Value()); + Assert.Null(newInvoice.Metadata["buyerCountry"]); + Assert.Equal(1000.19392922m, newInvoice.Amount); + Assert.Equal("BTC", newInvoice.Currency); + return newInvoice; + } + + await AssertInvoiceMetadata(); + + Logs.Tester.LogInformation("Let's hack the Bitpay created invoice to be just like before this update. (Invoice V1)"); + var invoiceV1 = "{\r\n \"version\": 1,\r\n \"id\": \"" + oldInvoice.Id + "\",\r\n \"storeId\": \"" + user.StoreId + "\",\r\n \"orderId\": \"orderId\",\r\n \"speedPolicy\": 1,\r\n \"rate\": 1.0,\r\n \"invoiceTime\": 1598329634,\r\n \"expirationTime\": 1598330534,\r\n \"depositAddress\": \"mm83rVs8ZnZok1SkRBmXiwQSiPFgTgCKpD\",\r\n \"productInformation\": {\r\n \"itemDesc\": \"desc\",\r\n \"itemCode\": \"code\",\r\n \"physical\": false,\r\n \"price\": 1000.19392922,\r\n \"currency\": \"BTC\"\r\n },\r\n \"buyerInformation\": {\r\n \"buyerName\": null,\r\n \"buyerEmail\": null,\r\n \"buyerCountry\": null,\r\n \"buyerZip\": null,\r\n \"buyerState\": null,\r\n \"buyerCity\": null,\r\n \"buyerAddress2\": \"blah2\",\r\n \"buyerAddress1\": \"blah\",\r\n \"buyerPhone\": null\r\n },\r\n \"posData\": \"posData\",\r\n \"internalTags\": [],\r\n \"derivationStrategy\": null,\r\n \"derivationStrategies\": \"{\\\"BTC\\\":{\\\"signingKey\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf\\\",\\\"source\\\":\\\"NBXplorer\\\",\\\"accountDerivation\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf-[legacy]\\\",\\\"accountOriginal\\\":null,\\\"accountKeySettings\\\":[{\\\"rootFingerprint\\\":\\\"54d5044d\\\",\\\"accountKeyPath\\\":\\\"44'/1'/0'\\\",\\\"accountKey\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf\\\"}],\\\"label\\\":null}}\",\r\n \"status\": \"new\",\r\n \"exceptionStatus\": \"\",\r\n \"payments\": [],\r\n \"refundable\": false,\r\n \"refundMail\": null,\r\n \"redirectURL\": null,\r\n \"redirectAutomatically\": false,\r\n \"txFee\": 0,\r\n \"fullNotifications\": false,\r\n \"notificationEmail\": null,\r\n \"notificationURL\": null,\r\n \"serverUrl\": \"http://127.0.0.1:8001\",\r\n \"cryptoData\": {\r\n \"BTC\": {\r\n \"rate\": 1.0,\r\n \"paymentMethod\": {\r\n \"networkFeeMode\": 0,\r\n \"networkFeeRate\": 100.0,\r\n \"payjoinEnabled\": false\r\n },\r\n \"feeRate\": 100.0,\r\n \"txFee\": 0,\r\n \"depositAddress\": \"mm83rVs8ZnZok1SkRBmXiwQSiPFgTgCKpD\"\r\n }\r\n },\r\n \"monitoringExpiration\": 1598416934,\r\n \"historicalAddresses\": null,\r\n \"availableAddressHashes\": null,\r\n \"extendedNotifications\": false,\r\n \"events\": null,\r\n \"paymentTolerance\": 0.0,\r\n \"archived\": false\r\n}"; + var db = tester.PayTester.GetService(); + using var ctx = db.CreateContext(); + var dbInvoice = await ctx.Invoices.FindAsync(oldInvoice.Id); + dbInvoice.Blob = ZipUtils.Zip(invoiceV1); + await ctx.SaveChangesAsync(); + var newInvoice = await AssertInvoiceMetadata(); + + Logs.Tester.LogInformation("Now, let's create an invoice with the new API but with the same metadata as Bitpay"); + newInvoice.Metadata.Add("lol", "lol"); + newInvoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() + { + Metadata = newInvoice.Metadata, + Amount = 1000.19392922m, + Currency = "BTC" + }); + oldInvoice = await oldBitpay.GetInvoiceAsync(newInvoice.Id); + await AssertInvoiceMetadata(); + Assert.Equal("lol", newInvoice.Metadata["lol"].Value()); + } + } + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task InvoiceTests() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.GrantAccessAsync(); + await user.MakeAdmin(); + var client = await user.CreateClient(Policies.Unrestricted); + var viewOnly = await user.CreateClient(Policies.CanViewInvoices); + + //create + + //validation errors + await AssertValidationError(new[] { nameof(CreateInvoiceRequest.Currency), nameof(CreateInvoiceRequest.Amount), $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentTolerance)}", $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentMethods)}[0]" }, async () => + { + await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions() { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } }); + }); + + await AssertHttpError(403, async () => + { + await viewOnly.CreateInvoice(user.StoreId, + new CreateInvoiceRequest() { Currency = "helloinvalid", Amount = 1 }); + }); + await user.RegisterDerivationSchemeAsync("BTC"); + var newInvoice = await client.CreateInvoice(user.StoreId, + new CreateInvoiceRequest() { Currency = "USD", Amount = 1, Metadata = JObject.Parse("{\"itemCode\": \"testitem\"}") }); + + //list + var invoices = await viewOnly.GetInvoices(user.StoreId); + + Assert.NotNull(invoices); + Assert.Single(invoices); + Assert.Equal(newInvoice.Id, invoices.First().Id); + + //get payment request + var invoice = await viewOnly.GetInvoice(user.StoreId, newInvoice.Id); + Assert.Equal(newInvoice.Metadata, invoice.Metadata); + + //update + invoice = await viewOnly.GetInvoice(user.StoreId, newInvoice.Id); + + await AssertValidationError(new[] { nameof(MarkInvoiceStatusRequest.Status) }, async () => + { + await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest() + { + Status = InvoiceStatus.Complete + }); + }); + + + //archive + await AssertHttpError(403, async () => + { + await viewOnly.ArchiveInvoice(user.StoreId, invoice.Id); + }); + + await client.ArchiveInvoice(user.StoreId, invoice.Id); + Assert.DoesNotContain(invoice.Id, + (await client.GetInvoices(user.StoreId)).Select(data => data.Id)); + + //unarchive + await client.UnarchiveInvoice(user.StoreId, invoice.Id); + Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id)); + + } + } + + + [Fact(Timeout = TestTimeout)] [Trait("Fast", "Fast")] public void NumericJsonConverterTests() @@ -762,10 +905,11 @@ namespace BTCPayServer.Tests Assert.Throws(() => { jsonConverter.ReadJson(Get("null"), typeof(decimal), null, null); - });Assert.Throws(() => - { - jsonConverter.ReadJson(Get("null"), typeof(double), null, null); }); + Assert.Throws(() => + { + jsonConverter.ReadJson(Get("null"), typeof(double), null, null); + }); Assert.Equal(1.2m, jsonConverter.ReadJson(Get(stringJson), typeof(decimal), null, null)); Assert.Equal(1.2m, jsonConverter.ReadJson(Get(stringJson), typeof(decimal?), null, null)); Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double), null, null)); diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 270d8c3e5..41825f703 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Events; diff --git a/BTCPayServer.Tests/PaymentRequestTests.cs b/BTCPayServer.Tests/PaymentRequestTests.cs index 4ef4f0659..df844addd 100644 --- a/BTCPayServer.Tests/PaymentRequestTests.cs +++ b/BTCPayServer.Tests/PaymentRequestTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.PaymentRequest; diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f0eced4c0..6788b6d00 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -151,6 +151,36 @@ namespace BTCPayServer.Tests } } + [Fact] + [Trait("Fast", "Fast")] + public void CanParsePaymentMethodId() + { + var id = PaymentMethodId.Parse("BTC"); + var id1 = PaymentMethodId.Parse("BTC-OnChain"); + var id2 = PaymentMethodId.Parse("BTC-BTCLike"); + Assert.Equal(id, id1); + Assert.Equal(id, id2); + Assert.Equal("BTC", id.ToString()); + Assert.Equal("BTC", id.ToString()); + id = PaymentMethodId.Parse("LTC"); + Assert.Equal("LTC", id.ToString()); + Assert.Equal("LTC", id.ToStringNormalized()); + id = PaymentMethodId.Parse("LTC-offchain"); + id1 = PaymentMethodId.Parse("LTC-OffChain"); + id2 = PaymentMethodId.Parse("LTC-LightningLike"); + Assert.Equal(id, id1); + Assert.Equal(id, id2); + Assert.Equal("LTC_LightningLike", id.ToString()); + Assert.Equal("LTC-LightningNetwork", id.ToStringNormalized()); +#if ALTCOINS + id = PaymentMethodId.Parse("XMR"); + id1 = PaymentMethodId.Parse("XMR-MoneroLike"); + Assert.Equal(id, id1); + Assert.Equal("XMR_MoneroLike", id.ToString()); + Assert.Equal("XMR", id.ToStringNormalized()); +#endif + } + [Fact] [Trait("Fast", "Fast")] public async Task CheckNoDeadLink() @@ -325,7 +355,7 @@ namespace BTCPayServer.Tests Assert.True(Torrc.TryParse(input, out torrc)); Assert.Equal(expected, torrc.ToString()); } - +#if ALTCOINS [Fact] [Trait("Fast", "Fast")] public void CanCalculateCryptoDue() @@ -346,7 +376,7 @@ namespace BTCPayServer.Tests Rate = 5000, NextNetworkFee = Money.Coins(0.1m) }); - entity.ProductInformation = new ProductInformation() { Price = 5000 }; + entity.Price = 5000; var paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike); var accounting = paymentMethod.Calculate(); @@ -396,7 +426,7 @@ namespace BTCPayServer.Tests entity = new InvoiceEntity(); entity.Networks = networkProvider; - entity.ProductInformation = new ProductInformation() { Price = 5000 }; + entity.Price = 5000; PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); paymentMethods.Add( new PaymentMethod() { CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m) }); @@ -490,7 +520,7 @@ namespace BTCPayServer.Tests Assert.Equal(accounting.Paid, accounting.TotalDue); #pragma warning restore CS0618 } - +#endif [Fact] [Trait("Integration", "Integration")] public async Task CanUseTestWebsiteUI() @@ -545,7 +575,7 @@ namespace BTCPayServer.Tests Rate = 5000, NextNetworkFee = Money.Coins(0.1m) }); - entity.ProductInformation = new ProductInformation() { Price = 5000 }; + entity.Price = 5000; entity.PaymentTolerance = 0; diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 134ac4af5..c18c150e9 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -246,5 +246,5 @@ <_ContentIncludedByDefault Remove="Views\Components\NotificationsDropdown\Default.cshtml" /> - + diff --git a/BTCPayServer/Controllers/AppsPublicController.cs b/BTCPayServer/Controllers/AppsPublicController.cs index 7da71f701..901044fd7 100644 --- a/BTCPayServer/Controllers/AppsPublicController.cs +++ b/BTCPayServer/Controllers/AppsPublicController.cs @@ -175,7 +175,7 @@ namespace BTCPayServer.Controllers var store = await _AppService.GetStore(app); try { - var invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest() + var invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest() { ItemCode = choice?.Id, ItemDesc = title, @@ -317,7 +317,7 @@ namespace BTCPayServer.Controllers try { - var invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest() + var invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest() { OrderId = AppService.GetCrowdfundOrderId(appId), Currency = settings.TargetCurrency, diff --git a/BTCPayServer/Controllers/GreenField/InvoiceController.cs b/BTCPayServer/Controllers/GreenField/InvoiceController.cs new file mode 100644 index 000000000..c80b4fe97 --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/InvoiceController.cs @@ -0,0 +1,242 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Payments; +using BTCPayServer.Security; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using NBitcoin; +using NBitpayClient; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using CreateInvoiceRequest = BTCPayServer.Client.Models.CreateInvoiceRequest; +using InvoiceData = BTCPayServer.Client.Models.InvoiceData; + +namespace BTCPayServer.Controllers.GreenField +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [EnableCors(CorsPolicies.All)] + public class GreenFieldInvoiceController : Controller + { + private readonly InvoiceController _invoiceController; + private readonly InvoiceRepository _invoiceRepository; + + public GreenFieldInvoiceController(InvoiceController invoiceController, InvoiceRepository invoiceRepository) + { + _invoiceController = invoiceController; + _invoiceRepository = invoiceRepository; + } + + [Authorize(Policy = Policies.CanViewInvoices, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/invoices")] + public async Task GetInvoices(string storeId, bool includeArchived = false) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + var invoices = + await _invoiceRepository.GetInvoices(new InvoiceQuery() + { + StoreId = new[] { store.Id }, + IncludeArchived = includeArchived + }); + + return Ok(invoices.Select(ToModel)); + } + + + [Authorize(Policy = Policies.CanViewInvoices, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/invoices/{invoiceId}")] + public async Task GetInvoice(string storeId, string invoiceId) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + var invoice = await _invoiceRepository.GetInvoice(invoiceId, true); + if (invoice.StoreId != store.Id) + { + return NotFound(); + } + + return Ok(ToModel(invoice)); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/invoices/{invoiceId}")] + public async Task ArchiveInvoice(string storeId, string invoiceId) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + await _invoiceRepository.ToggleInvoiceArchival(invoiceId, true, storeId); + return Ok(); + } + + [Authorize(Policy = Policies.CanCreateInvoice, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/invoices")] + public async Task CreateInvoice(string storeId, CreateInvoiceRequest request) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + if (request.Amount < 0.0m) + { + ModelState.AddModelError(nameof(request.Amount), "The amount should be 0 or more."); + } + + if (string.IsNullOrEmpty(request.Currency)) + { + ModelState.AddModelError(nameof(request.Currency), "Currency is required"); + } + + if (request.Checkout.PaymentMethods?.Any() is true) + { + for (int i = 0; i < request.Checkout.PaymentMethods.Length; i++) + { + if (!PaymentMethodId.TryParse(request.Checkout.PaymentMethods[i], out _)) + { + request.AddModelError(invoiceRequest => invoiceRequest.Checkout.PaymentMethods[i], + "Invalid payment method", this); + } + } + } + + if (request.Checkout.Expiration != null && request.Checkout.Expiration < TimeSpan.FromSeconds(30.0)) + { + request.AddModelError(invoiceRequest => invoiceRequest.Checkout.Expiration, + "Expiration time must be at least 30 seconds", this); + } + + if (request.Checkout.PaymentTolerance != null && + (request.Checkout.PaymentTolerance < 0 || request.Checkout.PaymentTolerance > 100)) + { + request.AddModelError(invoiceRequest => invoiceRequest.Checkout.PaymentTolerance, + "PaymentTolerance can only be between 0 and 100 percent", this); + } + + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + try + { + var invoice = await _invoiceController.CreateInvoiceCoreRaw(request, store, + Request.GetAbsoluteUri("")); + return Ok(ToModel(invoice)); + } + catch (BitpayHttpException e) + { + return this.CreateAPIError(null, e.Message); + } + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/status")] + public async Task MarkInvoiceStatus(string storeId, string invoiceId, + MarkInvoiceStatusRequest request) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + var invoice = await _invoiceRepository.GetInvoice(invoiceId, true); + if (invoice.StoreId != store.Id) + { + return NotFound(); + } + + if (!await _invoiceRepository.MarkInvoiceStatus(invoice.Id, request.Status)) + { + ModelState.AddModelError(nameof(request.Status), + "Status can only be marked to invalid or complete within certain conditions."); + } + + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + return await GetInvoice(storeId, invoiceId); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive")] + public async Task UnarchiveInvoice(string storeId, string invoiceId) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + + var invoice = await _invoiceRepository.GetInvoice(invoiceId, true); + if (invoice.StoreId != store.Id) + { + return NotFound(); + } + + if (!invoice.Archived) + { + return this.CreateAPIError("already-unarchived", "Invoice is already unarchived"); + } + + + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + await _invoiceRepository.ToggleInvoiceArchival(invoiceId, false, storeId); + return await GetInvoice(storeId, invoiceId); + } + + + private InvoiceData ToModel(InvoiceEntity entity) + { + return new InvoiceData() + { + ExpirationTime = entity.ExpirationTime, + MonitoringExpiration = entity.MonitoringExpiration, + CreatedTime = entity.InvoiceTime, + Amount = entity.Price, + Id = entity.Id, + Status = entity.Status, + AdditionalStatus = entity.ExceptionStatus, + Currency = entity.Currency, + Metadata = entity.Metadata.ToJObject(), + Checkout = new CreateInvoiceRequest.CheckoutOptions() + { + Expiration = entity.ExpirationTime - entity.InvoiceTime, + Monitoring = entity.MonitoringExpiration - entity.ExpirationTime, + PaymentTolerance = entity.PaymentTolerance, + PaymentMethods = + entity.GetPaymentMethods().Select(method => method.GetId().ToStringNormalized()).ToArray(), + SpeedPolicy = entity.SpeedPolicy + } + }; + } + } +} diff --git a/BTCPayServer/Controllers/GreenField/PaymentRequestsController.cs b/BTCPayServer/Controllers/GreenField/PaymentRequestsController.cs index 4f25999dc..583c5f978 100644 --- a/BTCPayServer/Controllers/GreenField/PaymentRequestsController.cs +++ b/BTCPayServer/Controllers/GreenField/PaymentRequestsController.cs @@ -32,10 +32,10 @@ namespace BTCPayServer.Controllers.GreenField [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/stores/{storeId}/payment-requests")] - public async Task>> GetPaymentRequests(string storeId) + public async Task>> GetPaymentRequests(string storeId, bool includeArchived = false) { var prs = await _paymentRequestRepository.FindPaymentRequests( - new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = false }); + new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = includeArchived }); return Ok(prs.Items.Select(FromModel)); } diff --git a/BTCPayServer/Controllers/GreenField/StoresController.cs b/BTCPayServer/Controllers/GreenField/StoresController.cs index 8ae2a6199..25b59fd82 100644 --- a/BTCPayServer/Controllers/GreenField/StoresController.cs +++ b/BTCPayServer/Controllers/GreenField/StoresController.cs @@ -124,8 +124,8 @@ namespace BTCPayServer.Controllers.GreenField ShowRecommendedFee = storeBlob.ShowRecommendedFee, RecommendedFeeBlockTarget = storeBlob.RecommendedFeeBlockTarget, DefaultLang = storeBlob.DefaultLang, - MonitoringExpiration = TimeSpan.FromMinutes(storeBlob.MonitoringExpiration), - InvoiceExpiration = TimeSpan.FromMinutes(storeBlob.InvoiceExpiration), + MonitoringExpiration = storeBlob.MonitoringExpiration, + InvoiceExpiration = storeBlob.InvoiceExpiration, LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi, CustomLogo = storeBlob.CustomLogo, CustomCSS = storeBlob.CustomCSS, @@ -160,8 +160,8 @@ namespace BTCPayServer.Controllers.GreenField blob.ShowRecommendedFee = restModel.ShowRecommendedFee; blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget; blob.DefaultLang = restModel.DefaultLang; - blob.MonitoringExpiration = (int)restModel.MonitoringExpiration.TotalMinutes; - blob.InvoiceExpiration = (int)restModel.InvoiceExpiration.TotalMinutes; + blob.MonitoringExpiration = restModel.MonitoringExpiration; + blob.InvoiceExpiration = restModel.InvoiceExpiration; blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi; blob.CustomLogo = restModel.CustomLogo; blob.CustomCSS = restModel.CustomCSS; diff --git a/BTCPayServer/Controllers/InvoiceController.API.cs b/BTCPayServer/Controllers/InvoiceController.API.cs index 52beaa07f..fca1ded2b 100644 --- a/BTCPayServer/Controllers/InvoiceController.API.cs +++ b/BTCPayServer/Controllers/InvoiceController.API.cs @@ -29,7 +29,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("invoices")] [MediaTypeConstraint("application/json")] - public async Task> CreateInvoice([FromBody] CreateInvoiceRequest invoice, CancellationToken cancellationToken) + public async Task> CreateInvoice([FromBody] BitpayCreateInvoiceRequest invoice, CancellationToken cancellationToken) { if (invoice == null) throw new BitpayHttpException(400, "Invalid invoice"); diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 4afb92f57..8a10a1e92 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -30,6 +30,7 @@ using NBitcoin; using NBitpayClient; using NBXplorer; using Newtonsoft.Json.Linq; +using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers @@ -52,7 +53,6 @@ namespace BTCPayServer.Controllers if (invoice == null) return NotFound(); - var prodInfo = invoice.ProductInformation; var store = await _StoreRepository.FindStore(invoice.StoreId); var model = new InvoiceDetailsModel() { @@ -68,16 +68,14 @@ namespace BTCPayServer.Controllers CreatedDate = invoice.InvoiceTime, ExpirationDate = invoice.ExpirationTime, MonitoringDate = invoice.MonitoringExpiration, - OrderId = invoice.OrderId, - BuyerInformation = invoice.BuyerInformation, - Fiat = _CurrencyNameTable.DisplayFormatCurrency(prodInfo.Price, prodInfo.Currency), - TaxIncluded = _CurrencyNameTable.DisplayFormatCurrency(prodInfo.TaxIncluded, prodInfo.Currency), + Fiat = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency), + TaxIncluded = _CurrencyNameTable.DisplayFormatCurrency(invoice.Metadata.TaxIncluded ?? 0.0m, invoice.Currency), NotificationUrl = invoice.NotificationURL?.AbsoluteUri, RedirectUrl = invoice.RedirectURL?.AbsoluteUri, - ProductInformation = invoice.ProductInformation, + TypedMetadata = invoice.Metadata, StatusException = invoice.ExceptionStatus, Events = invoice.Events, - PosData = PosDataParser.ParsePosData(invoice.PosData), + PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData), Archived = invoice.Archived, CanRefund = CanRefund(invoice.GetInvoiceState()), }; @@ -177,7 +175,7 @@ namespace BTCPayServer.Controllers if (!CanRefund(invoice.GetInvoiceState())) return NotFound(); var paymentMethodId = new PaymentMethodId(model.SelectedPaymentMethod, PaymentTypes.BTCLike); - var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.ProductInformation.Currency, true); + var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true); var paymentMethodDivisibility = _CurrencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8; if (model.SelectedRefundOption is null) { @@ -187,7 +185,7 @@ namespace BTCPayServer.Controllers model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility); model.RateThenText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode, true); var rules = store.GetStoreBlob().GetRateRules(_NetworkProvider); - var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.ProductInformation.Currency), rules, cancellationToken); + var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules, cancellationToken); //TODO: What if fetching rate failed? if (rateResult.BidAsk is null) { @@ -197,7 +195,7 @@ namespace BTCPayServer.Controllers model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility); model.CurrentRateText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode, true); model.FiatAmount = paidCurrency; - model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.ProductInformation.Currency, true); + model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency, true); return View(model); } else @@ -217,7 +215,7 @@ namespace BTCPayServer.Controllers createPullPayment.Amount = model.CryptoAmountNow; break; case "Fiat": - createPullPayment.Currency = invoice.ProductInformation.Currency; + createPullPayment.Currency = invoice.Currency; createPullPayment.Amount = model.FiatAmount; break; default: @@ -403,7 +401,7 @@ namespace BTCPayServer.Controllers var dto = invoice.EntityToDTO(); var cryptoInfo = dto.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId); var storeBlob = store.GetStoreBlob(); - var currency = invoice.ProductInformation.Currency; + var currency = invoice.Currency; var accounting = paymentMethod.Calculate(); ChangellySettings changelly = (storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled && @@ -425,12 +423,11 @@ namespace BTCPayServer.Controllers var paymentMethodHandler = _paymentMethodHandlerDictionary[paymentMethodId]; var divisibility = _CurrencyNameTable.GetNumberFormatInfo(paymentMethod.GetId().CryptoCode, false)?.CurrencyDecimalDigits; - var model = new PaymentModel() { CryptoCode = network.CryptoCode, RootPath = this.Request.PathBase.Value.WithTrailingSlash(), - OrderId = invoice.OrderId, + OrderId = invoice.Metadata.OrderId, InvoiceId = invoice.Id, DefaultLang = storeBlob.DefaultLang ?? "en", CustomCSSLink = storeBlob.CustomCSS, @@ -440,7 +437,7 @@ namespace BTCPayServer.Controllers BtcAddress = paymentMethodDetails.GetPaymentDestination(), BtcDue = accounting.Due.ShowMoney(divisibility), OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ShowMoney(divisibility), - OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice.ProductInformation), + OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice), CustomerEmail = invoice.RefundMail, RequiresRefundEmail = storeBlob.RequiresRefundEmail, ShowRecommendedFee = storeBlob.ShowRecommendedFee, @@ -448,7 +445,7 @@ namespace BTCPayServer.Controllers ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds), MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds, MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes, - ItemDesc = invoice.ProductInformation.ItemDesc, + ItemDesc = invoice.Metadata.ItemDesc, Rate = ExchangeRate(paymentMethod), MerchantRefLink = invoice.RedirectURL?.AbsoluteUri ?? "/", RedirectAutomatically = invoice.RedirectAutomatically, @@ -498,7 +495,7 @@ namespace BTCPayServer.Controllers paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob); if (model.IsLightning && storeBlob.LightningAmountInSatoshi && model.CryptoCode == "Sats") { - model.Rate = _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate / 100_000_000, paymentMethod.ParentEntity.ProductInformation.Currency); + model.Rate = _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate / 100_000_000, paymentMethod.ParentEntity.Currency); } model.UISettings = paymentMethodHandler.GetCheckoutUISettings(); model.PaymentMethodId = paymentMethodId.ToString(); @@ -507,17 +504,17 @@ namespace BTCPayServer.Controllers return model; } - private string OrderAmountFromInvoice(string cryptoCode, ProductInformation productInformation) + private string OrderAmountFromInvoice(string cryptoCode, InvoiceEntity invoiceEntity) { // if invoice source currency is the same as currently display currency, no need for "order amount from invoice" - if (cryptoCode == productInformation.Currency) + if (cryptoCode == invoiceEntity.Currency) return null; - return _CurrencyNameTable.DisplayFormatCurrency(productInformation.Price, productInformation.Currency); + return _CurrencyNameTable.DisplayFormatCurrency(invoiceEntity.Price, invoiceEntity.Currency); } private string ExchangeRate(PaymentMethod paymentMethod) { - string currency = paymentMethod.ParentEntity.ProductInformation.Currency; + string currency = paymentMethod.ParentEntity.Currency; return _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate, currency); } @@ -626,9 +623,9 @@ namespace BTCPayServer.Controllers ShowCheckout = invoice.Status == InvoiceStatus.New, Date = invoice.InvoiceTime, InvoiceId = invoice.Id, - OrderId = invoice.OrderId ?? string.Empty, + OrderId = invoice.Metadata.OrderId ?? string.Empty, RedirectUrl = invoice.RedirectURL?.AbsoluteUri ?? string.Empty, - AmountCurrency = _CurrencyNameTable.DisplayFormatCurrency(invoice.ProductInformation.Price, invoice.ProductInformation.Currency), + AmountCurrency = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency), CanMarkInvalid = state.CanMarkInvalid(), CanMarkComplete = state.CanMarkComplete(), Details = InvoicePopulatePayments(invoice), @@ -730,7 +727,7 @@ namespace BTCPayServer.Controllers try { - var result = await CreateInvoiceCore(new CreateInvoiceRequest() + var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest() { Price = model.Amount.Value, Currency = model.Currency, @@ -776,14 +773,12 @@ namespace BTCPayServer.Controllers } if (newState == "invalid") { - await _InvoiceRepository.UpdatePaidInvoiceToInvalid(invoiceId); - _EventAggregator.Publish(new InvoiceEvent(invoice, 1008, InvoiceEvent.MarkedInvalid)); + await _InvoiceRepository.MarkInvoiceStatus(invoiceId, InvoiceStatus.Invalid); model.StatusString = new InvoiceState("invalid", "marked").ToString(); } else if (newState == "complete") { - await _InvoiceRepository.UpdatePaidInvoiceToComplete(invoiceId); - _EventAggregator.Publish(new InvoiceEvent(invoice, 2008, InvoiceEvent.MarkedCompleted)); + await _InvoiceRepository.MarkInvoiceStatus(invoiceId, InvoiceStatus.Complete); model.StatusString = new InvoiceState("complete", "marked").ToString(); } diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index 8f4f0a7e9..a9a2a00a4 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -21,9 +21,10 @@ using BTCPayServer.Services.Stores; using BTCPayServer.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.CSharp.Syntax; using NBitpayClient; using Newtonsoft.Json; -using CreateInvoiceRequest = BTCPayServer.Models.CreateInvoiceRequest; +using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers @@ -72,40 +73,37 @@ namespace BTCPayServer.Controllers } - internal async Task> CreateInvoiceCore(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List additionalTags = null, CancellationToken cancellationToken = default) + internal async Task> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice, + StoreData store, string serverUrl, List additionalTags = null, + CancellationToken cancellationToken = default) { - invoice.Currency = invoice.Currency?.ToUpperInvariant() ?? "USD"; - InvoiceLogs logs = new InvoiceLogs(); - logs.Write("Creation of invoice starting"); - var entity = _InvoiceRepository.CreateNewInvoice(); + var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken); + var resp = entity.EntityToDTO(); + return new DataWrapper(resp) { Facade = "pos/invoice" }; + } - var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id); + internal async Task CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List additionalTags = null, CancellationToken cancellationToken = default) + { var storeBlob = store.GetStoreBlob(); - EmailAddressAttribute emailValidator = new EmailAddressAttribute(); - entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime.AddMinutes(storeBlob.InvoiceExpiration); + var entity = _InvoiceRepository.CreateNewInvoice(); + entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration; + entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration; if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime) { throw new BitpayHttpException(400, "The expirationTime is set too soon"); } - entity.MonitoringExpiration = entity.ExpirationTime + TimeSpan.FromMinutes(storeBlob.MonitoringExpiration); - entity.OrderId = invoice.OrderId; + invoice.Currency = invoice.Currency?.ToUpperInvariant() ?? "USD"; + entity.Currency = invoice.Currency; + entity.Metadata.OrderId = invoice.OrderId; + entity.Metadata.PosData = invoice.PosData; entity.ServerUrl = serverUrl; entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications; entity.ExtendedNotifications = invoice.ExtendedNotifications; entity.NotificationURLTemplate = invoice.NotificationURL; entity.NotificationEmail = invoice.NotificationEmail; - entity.BuyerInformation = Map(invoice); - entity.PaymentTolerance = storeBlob.PaymentTolerance; if (additionalTags != null) entity.InternalTags.AddRange(additionalTags); - //Another way of passing buyer info to support - FillBuyerInfo(invoice.Buyer, entity.BuyerInformation); - if (entity?.BuyerInformation?.BuyerEmail != null) - { - if (!EmailValidator.IsEmail(entity.BuyerInformation.BuyerEmail)) - throw new BitpayHttpException(400, "Invalid email"); - entity.RefundMail = entity.BuyerInformation.BuyerEmail; - } + FillBuyerInfo(invoice, entity); var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m; @@ -120,22 +118,19 @@ namespace BTCPayServer.Controllers invoice.Price = Math.Max(0.0m, invoice.Price); invoice.TaxIncluded = Math.Max(0.0m, taxIncluded); invoice.TaxIncluded = Math.Min(taxIncluded, invoice.Price); - - entity.ProductInformation = Map(invoice); - + entity.Metadata.ItemCode = invoice.ItemCode; + entity.Metadata.ItemDesc = invoice.ItemDesc; + entity.Metadata.Physical = invoice.Physical; + entity.Metadata.TaxIncluded = invoice.TaxIncluded; + entity.Currency = invoice.Currency; + entity.Price = invoice.Price; entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite; - entity.RedirectAutomatically = invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically); - - entity.Status = InvoiceStatus.New; entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy); - HashSet currencyPairsToFetch = new HashSet(); - var rules = storeBlob.GetRateRules(_NetworkProvider); - var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any() - + IPaymentFilter excludeFilter = null; if (invoice.PaymentCurrencies?.Any() is true) { invoice.SupportedTransactionCurrencies ??= @@ -151,17 +146,67 @@ namespace BTCPayServer.Controllers var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies .Where(c => c.Value.Enabled) .Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null) + .Where(c => c != null) .ToHashSet(); - excludeFilter = PaymentFilter.Or(excludeFilter, - PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p))); + excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p)); } + entity.PaymentTolerance = storeBlob.PaymentTolerance; + return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken); + } + internal async Task CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List additionalTags = null, CancellationToken cancellationToken = default) + { + var storeBlob = store.GetStoreBlob(); + var entity = _InvoiceRepository.CreateNewInvoice(); + entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration); + entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration); + if (invoice.Metadata != null) + entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata); + invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions(); + entity.Currency = invoice.Currency; + entity.Price = invoice.Amount; + entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy; + IPaymentFilter excludeFilter = null; + if (invoice.Checkout.PaymentMethods != null) + { + var supportedTransactionCurrencies = invoice.Checkout.PaymentMethods + .Select(c => PaymentMethodId.TryParse(c, out var p) ? p : null) + .ToHashSet(); + excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p)); + } + entity.PaymentTolerance = invoice.Checkout.PaymentTolerance ?? storeBlob.PaymentTolerance; + return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken); + } + + internal async Task CreateInvoiceCoreRaw(InvoiceEntity entity, StoreData store, IPaymentFilter invoicePaymentMethodFilter, CancellationToken cancellationToken = default) + { + InvoiceLogs logs = new InvoiceLogs(); + logs.Write("Creation of invoice starting"); + + var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id); + var storeBlob = store.GetStoreBlob(); + + if (entity.Metadata.BuyerEmail != null) + { + if (!EmailValidator.IsEmail(entity.Metadata.BuyerEmail)) + throw new BitpayHttpException(400, "Invalid email"); + entity.RefundMail = entity.Metadata.BuyerEmail; + } + entity.Status = InvoiceStatus.New; + HashSet currencyPairsToFetch = new HashSet(); + var rules = storeBlob.GetRateRules(_NetworkProvider); + var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any() + if (invoicePaymentMethodFilter != null) + { + excludeFilter = PaymentFilter.Or(excludeFilter, + invoicePaymentMethodFilter); + } foreach (var network in store.GetSupportedPaymentMethods(_NetworkProvider) .Where(s => !excludeFilter.Match(s.PaymentId)) .Select(c => _NetworkProvider.GetNetwork(c.PaymentId.CryptoCode)) .Where(c => c != null)) { - currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, invoice.Currency)); + currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, entity.Currency)); //TODO: abstract if (storeBlob.LightningMaxValue != null) currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, storeBlob.LightningMaxValue.Currency)); @@ -173,7 +218,12 @@ namespace BTCPayServer.Controllers var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules, cancellationToken); var fetchingAll = WhenAllFetched(logs, fetchingByCurrencyPair); - var supportedPaymentMethods = store.GetSupportedPaymentMethods(_NetworkProvider) + List supported = new List(); + var paymentMethods = new PaymentMethodDictionary(); + + // This loop ends with .ToList so we are querying all payment methods at once + // instead of sequentially to improve response time + foreach (var o in store.GetSupportedPaymentMethods(_NetworkProvider) .Where(s => !excludeFilter.Match(s.PaymentId) && _paymentMethodHandlerDictionary.Support(s.PaymentId)) .Select(c => (Handler: _paymentMethodHandlerDictionary[c.PaymentId], @@ -183,10 +233,7 @@ namespace BTCPayServer.Controllers .Select(o => (SupportedPaymentMethod: o.SupportedPaymentMethod, PaymentMethod: CreatePaymentMethodAsync(fetchingByCurrencyPair, o.Handler, o.SupportedPaymentMethod, o.Network, entity, store, logs))) - .ToList(); - List supported = new List(); - var paymentMethods = new PaymentMethodDictionary(); - foreach (var o in supportedPaymentMethods) + .ToList()) { var paymentMethod = await o.PaymentMethod; if (paymentMethod == null) @@ -209,8 +256,6 @@ namespace BTCPayServer.Controllers entity.SetSupportedPaymentMethods(supported); entity.SetPaymentMethods(paymentMethods); - entity.PosData = invoice.PosData; - foreach (var app in await getAppsTaggingStore) { entity.InternalTags.Add(AppService.GetAppInternalTag(app.Id)); @@ -232,9 +277,8 @@ namespace BTCPayServer.Controllers } await _InvoiceRepository.AddInvoiceLogs(entity.Id, logs); }); - _EventAggregator.Publish(new Events.InvoiceEvent(entity, 1001, InvoiceEvent.Created)); - var resp = entity.EntityToDTO(); - return new DataWrapper(resp) { Facade = "pos/invoice" }; + _EventAggregator.Publish(new Events.InvoiceEvent(entity, InvoiceEvent.Created)); + return entity; } private Task WhenAllFetched(InvoiceLogs logs, Dictionary> fetchingByCurrencyPair) @@ -263,7 +307,7 @@ namespace BTCPayServer.Controllers var logPrefix = $"{supportedPaymentMethod.PaymentId.ToPrettyString()}:"; var storeBlob = store.GetStoreBlob(); var preparePayment = handler.PreparePayment(supportedPaymentMethod, store, network); - var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.ProductInformation.Currency)]; + var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.Currency)]; if (rate.BidAsk == null) { return null; @@ -327,24 +371,30 @@ namespace BTCPayServer.Controllers return policy; } - private void FillBuyerInfo(Buyer buyer, BuyerInformation buyerInformation) + private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity) { + var buyerInformation = invoiceEntity.Metadata; + buyerInformation.BuyerAddress1 = req.BuyerAddress1; + buyerInformation.BuyerAddress2 = req.BuyerAddress2; + buyerInformation.BuyerCity = req.BuyerCity; + buyerInformation.BuyerCountry = req.BuyerCountry; + buyerInformation.BuyerEmail = req.BuyerEmail; + buyerInformation.BuyerName = req.BuyerName; + buyerInformation.BuyerPhone = req.BuyerPhone; + buyerInformation.BuyerState = req.BuyerState; + buyerInformation.BuyerZip = req.BuyerZip; + var buyer = req.Buyer; if (buyer == null) return; - buyerInformation.BuyerAddress1 = buyerInformation.BuyerAddress1 ?? buyer.Address1; - buyerInformation.BuyerAddress2 = buyerInformation.BuyerAddress2 ?? buyer.Address2; - buyerInformation.BuyerCity = buyerInformation.BuyerCity ?? buyer.City; - buyerInformation.BuyerCountry = buyerInformation.BuyerCountry ?? buyer.country; - buyerInformation.BuyerEmail = buyerInformation.BuyerEmail ?? buyer.email; - buyerInformation.BuyerName = buyerInformation.BuyerName ?? buyer.Name; - buyerInformation.BuyerPhone = buyerInformation.BuyerPhone ?? buyer.phone; - buyerInformation.BuyerState = buyerInformation.BuyerState ?? buyer.State; - buyerInformation.BuyerZip = buyerInformation.BuyerZip ?? buyer.zip; - } - - private TDest Map(TFrom data) - { - return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(data)); + buyerInformation.BuyerAddress1 ??= buyer.Address1; + buyerInformation.BuyerAddress2 ??= buyer.Address2; + buyerInformation.BuyerCity ??= buyer.City; + buyerInformation.BuyerCountry ??= buyer.country; + buyerInformation.BuyerEmail ??= buyer.email; + buyerInformation.BuyerName ??= buyer.Name; + buyerInformation.BuyerPhone ??= buyer.phone; + buyerInformation.BuyerState ??= buyer.State; + buyerInformation.BuyerZip ??= buyer.zip; } } } diff --git a/BTCPayServer/Controllers/ManageController.APIKeys.cs b/BTCPayServer/Controllers/ManageController.APIKeys.cs index a0499d0b5..ba5ce104e 100644 --- a/BTCPayServer/Controllers/ManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/ManageController.APIKeys.cs @@ -363,6 +363,8 @@ namespace BTCPayServer.Controllers {BTCPayServer.Client.Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")}, {BTCPayServer.Client.Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")}, {$"{BTCPayServer.Client.Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")}, + {BTCPayServer.Client.Policies.CanViewInvoices, ("View invoices", "The app will be able to view invoices.")}, + {$"{BTCPayServer.Client.Policies.CanViewInvoices}:", ("View invoices", "The app will be able to view invoices on the selected stores.")}, {BTCPayServer.Client.Policies.CanModifyPaymentRequests, ("Modify your payment requests", "The app will be able to view, modify, delete and create new payment requests on all your stores.")}, {$"{BTCPayServer.Client.Policies.CanModifyPaymentRequests}:", ("Manage selected stores' payment requests", "The app will be able to view, modify, delete and create new payment requests on the selected stores.")}, {BTCPayServer.Client.Policies.CanViewPaymentRequests, ("View your payment requests", "The app will be able to view payment requests.")}, diff --git a/BTCPayServer/Controllers/PaymentRequestController.cs b/BTCPayServer/Controllers/PaymentRequestController.cs index ebbcd790b..0c75609cb 100644 --- a/BTCPayServer/Controllers/PaymentRequestController.cs +++ b/BTCPayServer/Controllers/PaymentRequestController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Filters; @@ -19,6 +20,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Routing; +using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest; +using PaymentRequestData = BTCPayServer.Data.PaymentRequestData; +using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers { @@ -256,7 +260,7 @@ namespace BTCPayServer.Controllers try { var redirectUrl = _linkGenerator.PaymentRequestLink(id, Request.Scheme, Request.Host, Request.PathBase); - var newInvoiceId = (await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest() + var newInvoiceId = (await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest() { OrderId = $"{PaymentRequestRepository.GetOrderIdForPaymentRequest(id)}", Currency = blob.Currency, @@ -303,9 +307,7 @@ namespace BTCPayServer.Controllers foreach (var invoice in invoices) { - await _InvoiceRepository.UpdatePaidInvoiceToInvalid(invoice.Id); - _EventAggregator.Publish(new InvoiceEvent(await _InvoiceRepository.GetInvoice(invoice.Id), 1008, - InvoiceEvent.MarkedInvalid)); + await _InvoiceRepository.MarkInvoiceStatus(invoice.Id, InvoiceStatus.Invalid); } if (redirect) diff --git a/BTCPayServer/Controllers/PublicController.cs b/BTCPayServer/Controllers/PublicController.cs index c29eb6380..dd5c81350 100644 --- a/BTCPayServer/Controllers/PublicController.cs +++ b/BTCPayServer/Controllers/PublicController.cs @@ -57,7 +57,7 @@ namespace BTCPayServer.Controllers DataWrapper invoice = null; try { - invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest() + invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest() { Price = model.Price, Currency = model.Currency, diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 99384dcde..6392f1564 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -481,8 +481,8 @@ namespace BTCPayServer.Controllers vm.SpeedPolicy = store.SpeedPolicy; vm.CanDelete = _Repo.CanDeleteStores(); AddPaymentMethods(store, storeBlob, vm); - vm.MonitoringExpiration = storeBlob.MonitoringExpiration; - vm.InvoiceExpiration = storeBlob.InvoiceExpiration; + vm.MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes; + vm.InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; vm.PaymentTolerance = storeBlob.PaymentTolerance; vm.PayJoinEnabled = storeBlob.PayJoinEnabled; @@ -579,8 +579,8 @@ namespace BTCPayServer.Controllers var blob = CurrentStore.GetStoreBlob(); blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice; blob.NetworkFeeMode = model.NetworkFeeMode; - blob.MonitoringExpiration = model.MonitoringExpiration; - blob.InvoiceExpiration = model.InvoiceExpiration; + blob.MonitoringExpiration = TimeSpan.FromMinutes(model.MonitoringExpiration); + blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration); blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; blob.PaymentTolerance = model.PaymentTolerance; var payjoinChanged = blob.PayJoinEnabled != model.PayJoinEnabled; diff --git a/BTCPayServer/Data/InvoiceDataExtensions.cs b/BTCPayServer/Data/InvoiceDataExtensions.cs index 1cfd65ea9..16bb3fd25 100644 --- a/BTCPayServer/Data/InvoiceDataExtensions.cs +++ b/BTCPayServer/Data/InvoiceDataExtensions.cs @@ -1,4 +1,5 @@ using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Authorization.Infrastructure; namespace BTCPayServer.Data { @@ -8,6 +9,17 @@ namespace BTCPayServer.Data { var entity = NBitcoin.JsonConverters.Serializer.ToObject(ZipUtils.Unzip(invoiceData.Blob), null); entity.Networks = networks; + if (entity.Metadata is null) + { + if (entity.Version < InvoiceEntity.GreenfieldInvoices_Version) + { + entity.MigrateLegacyInvoice(); + } + else + { + entity.Metadata = new InvoiceMetadata(); + } + } return entity; } public static InvoiceState GetInvoiceState(this InvoiceData invoiceData) diff --git a/BTCPayServer/Data/PullPaymentsExtensions.cs b/BTCPayServer/Data/PullPaymentsExtensions.cs index b29e872b8..eddb1080e 100644 --- a/BTCPayServer/Data/PullPaymentsExtensions.cs +++ b/BTCPayServer/Data/PullPaymentsExtensions.cs @@ -195,7 +195,7 @@ namespace BTCPayServer.Data [JsonConverter(typeof(NumericStringJsonConverter))] public decimal MinimumClaim { get; set; } public PullPaymentView View { get; set; } = new PullPaymentView(); - [JsonConverter(typeof(TimeSpanJsonConverter))] + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] public TimeSpan? Period { get; set; } [JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))] diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 44d4267c5..1c76b6421 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Text; +using BTCPayServer.Client.JsonConverters; using BTCPayServer.Client.Models; using BTCPayServer.JsonConverters; using BTCPayServer.Payments; @@ -19,8 +20,8 @@ namespace BTCPayServer.Data { public StoreBlob() { - InvoiceExpiration = 15; - MonitoringExpiration = 1440; + InvoiceExpiration = TimeSpan.FromMinutes(15); + MonitoringExpiration = TimeSpan.FromDays(1); PaymentTolerance = 0; ShowRecommendedFee = true; RecommendedFeeBlockTarget = 1; @@ -66,17 +67,19 @@ namespace BTCPayServer.Data } public string DefaultLang { get; set; } - [DefaultValue(60)] + [DefaultValue(typeof(TimeSpan), "1.00:00:00")] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] - public int MonitoringExpiration + [JsonConverter(typeof(TimeSpanJsonConverter.Minutes))] + public TimeSpan MonitoringExpiration { get; set; } - [DefaultValue(15)] + [DefaultValue(typeof(TimeSpan), "00:15:00")] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] - public int InvoiceExpiration + [JsonConverter(typeof(TimeSpanJsonConverter.Minutes))] + public TimeSpan InvoiceExpiration { get; set; diff --git a/BTCPayServer/Events/InvoiceEvent.cs b/BTCPayServer/Events/InvoiceEvent.cs index b55f7384d..ede5028ca 100644 --- a/BTCPayServer/Events/InvoiceEvent.cs +++ b/BTCPayServer/Events/InvoiceEvent.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using BTCPayServer.Services.Invoices; namespace BTCPayServer.Events @@ -15,11 +16,26 @@ namespace BTCPayServer.Events public const string FailedToConfirm = "invoice_failedToConfirm"; public const string Confirmed = "invoice_confirmed"; public const string Completed = "invoice_completed"; + + public static Dictionary EventCodes = new Dictionary() + { + {Created, 1001}, + {ReceivedPayment, 1002}, + {PaidInFull, 1003}, + {Expired, 1004}, + {Confirmed, 1005}, + {Completed, 1006}, + {MarkedInvalid, 1008}, + {FailedToConfirm, 1013}, + {PaidAfterExpiration, 1009}, + {ExpiredPaidPartial, 2000}, + {MarkedCompleted, 2008}, + }; - public InvoiceEvent(InvoiceEntity invoice, int code, string name) + public InvoiceEvent(InvoiceEntity invoice, string name) { Invoice = invoice; - EventCode = code; + EventCode = EventCodes[name]; Name = name; } diff --git a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs index 665c5c1e4..6a89c9e13 100644 --- a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs +++ b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs @@ -104,9 +104,8 @@ namespace BTCPayServer.HostedServices return; } - - if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) || - AppService.TryParsePosCartItems(invoiceEvent.Invoice.PosData, out cartItems))) + if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) || + AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems))) { var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice); @@ -116,9 +115,9 @@ namespace BTCPayServer.HostedServices } var items = cartItems ?? new Dictionary(); - if (!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode)) + if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode)) { - items.TryAdd(invoiceEvent.Invoice.ProductInformation.ItemCode, 1); + items.TryAdd(invoiceEvent.Invoice.Metadata.ItemCode, 1); } _eventAggregator.Publish(new UpdateAppInventory() diff --git a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs index 83133f511..5502104c6 100644 --- a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs +++ b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Events; using BTCPayServer.Payments; using BTCPayServer.Services; diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index c3a94114b..af3101f72 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Services.Invoices; @@ -65,9 +66,9 @@ namespace BTCPayServer.HostedServices await _InvoiceRepository.UnaffectAddress(invoice.Id); invoice.Status = InvoiceStatus.Expired; - context.Events.Add(new InvoiceEvent(invoice, 1004, InvoiceEvent.Expired)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Expired)); if (invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial) - context.Events.Add(new InvoiceEvent(invoice, 2000, InvoiceEvent.ExpiredPaidPartial)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.ExpiredPaidPartial)); } var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray(); @@ -81,7 +82,7 @@ namespace BTCPayServer.HostedServices { if (invoice.Status == InvoiceStatus.New) { - context.Events.Add(new InvoiceEvent(invoice, 1003, InvoiceEvent.PaidInFull)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidInFull)); invoice.Status = InvoiceStatus.Paid; invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None; await _InvoiceRepository.UnaffectAddress(invoice.Id); @@ -90,7 +91,7 @@ namespace BTCPayServer.HostedServices else if (invoice.Status == InvoiceStatus.Expired && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidLate) { invoice.ExceptionStatus = InvoiceExceptionStatus.PaidLate; - context.Events.Add(new InvoiceEvent(invoice, 1009, InvoiceEvent.PaidAfterExpiration)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidAfterExpiration)); context.MarkDirty(); } } @@ -136,7 +137,7 @@ namespace BTCPayServer.HostedServices (confirmedAccounting.Paid < accounting.MinimumTotalDue)) { await _InvoiceRepository.UnaffectAddress(invoice.Id); - context.Events.Add(new InvoiceEvent(invoice, 1013, InvoiceEvent.FailedToConfirm)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.FailedToConfirm)); invoice.Status = InvoiceStatus.Invalid; context.MarkDirty(); } @@ -144,7 +145,7 @@ namespace BTCPayServer.HostedServices { await _InvoiceRepository.UnaffectAddress(invoice.Id); invoice.Status = InvoiceStatus.Confirmed; - context.Events.Add(new InvoiceEvent(invoice, 1005, InvoiceEvent.Confirmed)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Confirmed)); context.MarkDirty(); } } @@ -154,7 +155,7 @@ namespace BTCPayServer.HostedServices var completedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p)); if (completedAccounting.Paid >= accounting.MinimumTotalDue) { - context.Events.Add(new InvoiceEvent(invoice, 1006, InvoiceEvent.Completed)); + context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Completed)); invoice.Status = InvoiceStatus.Complete; context.MarkDirty(); } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 04fb29eeb..ebcaa9fe6 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -96,7 +96,7 @@ namespace BTCPayServer.Hosting var dbpath = Path.Combine(opts.DataDir, "InvoiceDB"); if (!Directory.Exists(dbpath)) Directory.CreateDirectory(dbpath); - return new InvoiceRepository(dbContext, dbpath, o.GetRequiredService()); + return new InvoiceRepository(dbContext, dbpath, o.GetRequiredService(), o.GetService()); }); services.AddSingleton(); services.TryAddSingleton(); diff --git a/BTCPayServer/Models/CreateInvoiceRequest.cs b/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs similarity index 98% rename from BTCPayServer/Models/CreateInvoiceRequest.cs rename to BTCPayServer/Models/BitpayCreateInvoiceRequest.cs index 74a0b20e4..d5112a369 100644 --- a/BTCPayServer/Models/CreateInvoiceRequest.cs +++ b/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs @@ -2,10 +2,11 @@ using System; using System.Collections.Generic; using NBitpayClient; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Models { - public class CreateInvoiceRequest + public class BitpayCreateInvoiceRequest { [JsonProperty(PropertyName = "buyer")] public Buyer Buyer { get; set; } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 4ba1f46ef..2052f3365 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using BTCPayServer.Client.Models; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Services.Invoices; @@ -73,22 +74,12 @@ namespace BTCPayServer.Models.InvoicingModels { get; set; } - - public string OrderId - { - get; set; - } public string RefundEmail { get; set; } public string TaxIncluded { get; set; } - public BuyerInformation BuyerInformation - { - get; - set; - } public string TransactionSpeed { get; set; } public object StoreName @@ -113,11 +104,7 @@ namespace BTCPayServer.Models.InvoicingModels get; set; } - public ProductInformation ProductInformation - { - get; - internal set; - } + public InvoiceMetadata TypedMetadata { get; set; } public AddressModel[] Addresses { get; set; } public DateTimeOffset MonitoringDate { get; internal set; } public List Events { get; internal set; } diff --git a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs index 1e109a706..001ddd973 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using BTCPayServer.Client.Models; using BTCPayServer.Services.Invoices; namespace BTCPayServer.Models.InvoicingModels diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index da6f7cbaf..a7c75ae3c 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.Payments; @@ -9,6 +10,7 @@ using BTCPayServer.Services.Invoices; using BTCPayServer.Services.PaymentRequests; using BTCPayServer.Services.Rates; using Microsoft.AspNetCore.SignalR; +using PaymentRequestData = BTCPayServer.Data.PaymentRequestData; namespace BTCPayServer.PaymentRequest { @@ -99,9 +101,9 @@ namespace BTCPayServer.PaymentRequest Invoices = invoices.Select(entity => new ViewPaymentRequestViewModel.PaymentRequestInvoice() { Id = entity.Id, - Amount = entity.ProductInformation.Price, - AmountFormatted = _currencies.FormatCurrency(entity.ProductInformation.Price, blob.Currency), - Currency = entity.ProductInformation.Currency, + Amount = entity.Price, + AmountFormatted = _currencies.FormatCurrency(entity.Price, blob.Currency), + Currency = entity.Currency, ExpiryDate = entity.ExpirationTime.DateTime, Status = entity.GetInvoiceState().ToString(), Payments = entity diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 243ab0f55..0f61f54f5 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -406,7 +406,7 @@ namespace BTCPayServer.Payments.Bitcoin invoice.SetPaymentMethod(paymentMethod); } wallet.InvalidateCache(strategy); - _Aggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); + _Aggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); return invoice; } public async Task StopAsync(CancellationToken cancellationToken) diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index 49ac375b8..4fb7c869c 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -48,7 +48,7 @@ namespace BTCPayServer.Payments.Lightning var storeBlob = store.GetStoreBlob(); var test = GetNodeInfo(paymentMethod.PreferOnion, supportedPaymentMethod, network); var invoice = paymentMethod.ParentEntity; - var due = Extensions.RoundUp(invoice.ProductInformation.Price / paymentMethod.Rate, network.Divisibility); + var due = Extensions.RoundUp(invoice.Price / paymentMethod.Rate, network.Divisibility); var client = _lightningClientFactory.Create(supportedPaymentMethod.GetLightningUrl(), network); var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; if (expiry < TimeSpan.Zero) @@ -58,8 +58,8 @@ namespace BTCPayServer.Payments.Lightning string description = storeBlob.LightningDescriptionTemplate; description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{ItemDescription}", invoice.ProductInformation.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{OrderId}", invoice.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + .Replace("{ItemDescription}", invoice.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", invoice.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); using (var cts = new CancellationTokenSource(LIGHTNING_TIMEOUT)) { try diff --git a/BTCPayServer/Payments/Lightning/LightningListener.cs b/BTCPayServer/Payments/Lightning/LightningListener.cs index ac09e1650..4c8da4d52 100644 --- a/BTCPayServer/Payments/Lightning/LightningListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningListener.cs @@ -329,7 +329,7 @@ namespace BTCPayServer.Payments.Lightning { var invoice = await invoiceRepository.GetInvoice(invoiceId); if (invoice != null) - _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); + _eventAggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); } return payment != null; } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 56666857e..98e57abbf 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -454,7 +454,7 @@ namespace BTCPayServer.Payments.PayJoin $"The original transaction has already been accounted")); } await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction); - _eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); + _eventAggregator.Publish(new InvoiceEvent(invoice,InvoiceEvent.ReceivedPayment) { Payment = payment }); _eventAggregator.Publish(new UpdateTransactionLabel() { WalletId = new WalletId(invoice.StoreId, network.CryptoCode), diff --git a/BTCPayServer/Payments/PaymentMethodId.cs b/BTCPayServer/Payments/PaymentMethodId.cs index 77a047af5..37a902548 100644 --- a/BTCPayServer/Payments/PaymentMethodId.cs +++ b/BTCPayServer/Payments/PaymentMethodId.cs @@ -64,20 +64,38 @@ namespace BTCPayServer.Payments //BTCLike case is special because it is in legacy mode. return PaymentType == PaymentTypes.BTCLike ? CryptoCode : $"{CryptoCode}_{PaymentType}"; } + /// + /// A string we can expose to Greenfield API, not subjected to internal legacy + /// + /// + public string ToStringNormalized() + { + if (PaymentType == PaymentTypes.BTCLike) + return CryptoCode; +#if ALTCOINS + if (CryptoCode == "XMR" && PaymentType == PaymentTypes.MoneroLike) + return CryptoCode; +#endif + return $"{CryptoCode}-{PaymentType.ToStringNormalized()}"; + } public string ToPrettyString() { return $"{CryptoCode} ({PaymentType.ToPrettyString()})"; } - + static char[] Separators = new[] { '_', '-' }; public static bool TryParse(string str, out PaymentMethodId paymentMethodId) { str ??= ""; paymentMethodId = null; - var parts = str.Split('_', StringSplitOptions.RemoveEmptyEntries); + var parts = str.Split(Separators, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0 || parts.Length > 2) return false; PaymentType type = PaymentTypes.BTCLike; +#if ALTCOINS + if (parts[0].ToUpperInvariant() == "XMR") + type = PaymentTypes.MoneroLike; +#endif if (parts.Length == 2) { if (!PaymentTypes.TryParse(parts[1], out type)) diff --git a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs index a6fe91f54..ffdbd7b12 100644 --- a/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs +++ b/BTCPayServer/Payments/PaymentTypes.Bitcoin.cs @@ -19,6 +19,10 @@ namespace BTCPayServer.Payments public override string ToPrettyString() => "On-Chain"; public override string GetId() => "BTCLike"; + public override string ToStringNormalized() + { + return "OnChain"; + } public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str) { diff --git a/BTCPayServer/Payments/PaymentTypes.Lightning.cs b/BTCPayServer/Payments/PaymentTypes.Lightning.cs index 9236d39f7..999443d2c 100644 --- a/BTCPayServer/Payments/PaymentTypes.Lightning.cs +++ b/BTCPayServer/Payments/PaymentTypes.Lightning.cs @@ -17,7 +17,10 @@ namespace BTCPayServer.Payments public override string ToPrettyString() => "Off-Chain"; public override string GetId() => "LightningLike"; - + public override string ToStringNormalized() + { + return "LightningNetwork"; + } public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str) { return ((BTCPayNetwork)network)?.ToObject(str); diff --git a/BTCPayServer/Payments/PaymentTypes.cs b/BTCPayServer/Payments/PaymentTypes.cs index 391c79c30..6ca9c0cf2 100644 --- a/BTCPayServer/Payments/PaymentTypes.cs +++ b/BTCPayServer/Payments/PaymentTypes.cs @@ -22,6 +22,13 @@ namespace BTCPayServer.Payments /// public static LightningPaymentType LightningLike => LightningPaymentType.Instance; +#if ALTCOINS + /// + /// Monero payment + /// + public static MoneroPaymentType MoneroLike => MoneroPaymentType.Instance; +#endif + public static bool TryParse(string paymentType, out PaymentType type) { switch (paymentType.ToLowerInvariant()) @@ -36,7 +43,7 @@ namespace BTCPayServer.Payments break; #if ALTCOINS case "monerolike": - type = MoneroPaymentType.Instance; + type = PaymentTypes.MoneroLike; break; #endif default: @@ -61,6 +68,15 @@ namespace BTCPayServer.Payments return GetId(); } + /// + /// A string we can expose to Greenfield API, not subjected to internal legacy + /// + /// + public virtual string ToStringNormalized() + { + return ToString(); + } + public abstract string GetId(); public abstract CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str); public abstract string SerializePaymentData(BTCPayNetworkBase network, CryptoPaymentData paymentData); diff --git a/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroPaymentType.cs b/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroPaymentType.cs index 184d6c1d6..cdfe86260 100644 --- a/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroPaymentType.cs +++ b/BTCPayServer/Services/Altcoins/Monero/Payments/MoneroPaymentType.cs @@ -14,7 +14,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments public override string ToPrettyString() => "On-Chain"; public override string GetId() => "MoneroLike"; - + public override string ToStringNormalized() + { + return "Monero"; + } public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str) { diff --git a/BTCPayServer/Services/Altcoins/Monero/Services/MoneroListener.cs b/BTCPayServer/Services/Altcoins/Monero/Services/MoneroListener.cs index 95d9e0fb8..ee17ce646 100644 --- a/BTCPayServer/Services/Altcoins/Monero/Services/MoneroListener.cs +++ b/BTCPayServer/Services/Altcoins/Monero/Services/MoneroListener.cs @@ -143,7 +143,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services } _eventAggregator.Publish( - new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment }); + new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); } private async Task UpdatePaymentStates(string cryptoCode, InvoiceEntity[] invoices) diff --git a/BTCPayServer/Services/Apps/AppService.cs b/BTCPayServer/Services/Apps/AppService.cs index 2a1f92abd..400a334d2 100644 --- a/BTCPayServer/Services/Apps/AppService.cs +++ b/BTCPayServer/Services/Apps/AppService.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; using BTCPayServer.Payments; @@ -20,6 +21,7 @@ using NUglify.Helpers; using YamlDotNet.RepresentationModel; using YamlDotNet.Serialization; using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel; +using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Services.Apps { @@ -95,8 +97,8 @@ namespace BTCPayServer.Services.Apps var currentPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount); var perkCount = paidInvoices - .Where(entity => !string.IsNullOrEmpty(entity.ProductInformation.ItemCode)) - .GroupBy(entity => entity.ProductInformation.ItemCode) + .Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode)) + .GroupBy(entity => entity.Metadata.ItemCode) .ToDictionary(entities => entities.Key, entities => entities.Count()); var perks = Parse(settings.PerksTemplate, settings.TargetCurrency); @@ -329,12 +331,12 @@ namespace BTCPayServer.Services.Apps public Contributions GetContributionsByPaymentMethodId(string currency, InvoiceEntity[] invoices, bool softcap) { var contributions = invoices - .Where(p => p.ProductInformation.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)) + .Where(p => p.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)) .SelectMany(p => { var contribution = new Contribution(); - contribution.PaymentMethodId = new PaymentMethodId(p.ProductInformation.Currency, PaymentTypes.BTCLike); - contribution.CurrencyValue = p.ProductInformation.Price; + contribution.PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike); + contribution.CurrencyValue = p.Price; contribution.Value = contribution.CurrencyValue; // For hardcap, we count newly created invoices as part of the contributions diff --git a/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs b/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs index 81a122a45..dfa7e97ce 100644 --- a/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs +++ b/BTCPayServer/Services/Invoices/Export/InvoiceExport.cs @@ -56,9 +56,8 @@ namespace BTCPayServer.Services.Invoices.Export private IEnumerable convertFromDb(InvoiceEntity invoice) { var exportList = new List(); - var currency = Currencies.GetNumberFormatInfo(invoice.ProductInformation.Currency, true); - - var invoiceDue = invoice.ProductInformation.Price; + var currency = Currencies.GetNumberFormatInfo(invoice.Currency, true); + var invoiceDue = invoice.Price; // in this first version we are only exporting invoices that were paid foreach (var payment in invoice.GetPayments()) { @@ -88,7 +87,7 @@ namespace BTCPayServer.Services.Invoices.Export // while looking just at export you could sum Paid and assume merchant "received payments" NetworkFee = payment.NetworkFee.ToString(CultureInfo.InvariantCulture), InvoiceDue = Math.Round(invoiceDue, currency.NumberDecimalDigits), - OrderId = invoice.OrderId, + OrderId = invoice.Metadata.OrderId ?? string.Empty, StoreId = invoice.StoreId, InvoiceId = invoice.Id, InvoiceCreatedDate = invoice.InvoiceTime.UtcDateTime, @@ -99,11 +98,11 @@ namespace BTCPayServer.Services.Invoices.Export InvoiceStatus = invoice.StatusString, InvoiceExceptionStatus = invoice.ExceptionStatusString, #pragma warning restore CS0618 // Type or member is obsolete - InvoiceItemCode = invoice.ProductInformation.ItemCode, - InvoiceItemDesc = invoice.ProductInformation.ItemDesc, - InvoicePrice = invoice.ProductInformation.Price, - InvoiceCurrency = invoice.ProductInformation.Currency, - BuyerEmail = invoice.BuyerInformation?.BuyerEmail + InvoiceItemCode = invoice.Metadata.ItemCode, + InvoiceItemDesc = invoice.Metadata.ItemDesc, + InvoicePrice = invoice.Price, + InvoiceCurrency = invoice.Currency, + BuyerEmail = invoice.Metadata.BuyerEmail }; exportList.Add(target); diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 30948bc94..aff8665a4 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using Amazon.Runtime.Internal.Util; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.JsonConverters; @@ -9,17 +10,31 @@ using BTCPayServer.Models; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.CodeAnalysis; using NBitcoin; using NBitcoin.DataEncoders; using NBitpayClient; using NBXplorer; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using YamlDotNet.Core.Tokens; +using YamlDotNet.Serialization.NamingConventions; namespace BTCPayServer.Services.Invoices { - public class BuyerInformation + public class InvoiceMetadata { + public static readonly JsonSerializer MetadataSerializer; + static InvoiceMetadata() + { + var seria = new JsonSerializer(); + seria.DefaultValueHandling = DefaultValueHandling.Ignore; + seria.FloatParseHandling = FloatParseHandling.Decimal; + seria.ContractResolver = new CamelCasePropertyNamesContractResolver(); + MetadataSerializer = seria; + } + public string OrderId { get; set; } [JsonProperty(PropertyName = "buyerName")] public string BuyerName { @@ -66,10 +81,7 @@ namespace BTCPayServer.Services.Invoices { get; set; } - } - public class ProductInformation - { [JsonProperty(PropertyName = "itemDesc")] public string ItemDesc { @@ -81,35 +93,122 @@ namespace BTCPayServer.Services.Invoices get; set; } [JsonProperty(PropertyName = "physical")] - public bool Physical - { - get; set; - } - - [JsonProperty(PropertyName = "price")] - public decimal Price + public bool? Physical { get; set; } [JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)] - public decimal TaxIncluded + public decimal? TaxIncluded { get; set; } + public string PosData { get; set; } + [JsonExtensionData] + public IDictionary AdditionalData { get; set; } - [JsonProperty(PropertyName = "currency")] - public string Currency + public static InvoiceMetadata FromJObject(JObject jObject) { - get; set; + return jObject.ToObject(MetadataSerializer); + } + public JObject ToJObject() + { + return JObject.FromObject(this, MetadataSerializer); } } + public class InvoiceEntity { + class BuyerInformation + { + [JsonProperty(PropertyName = "buyerName")] + public string BuyerName + { + get; set; + } + [JsonProperty(PropertyName = "buyerEmail")] + public string BuyerEmail + { + get; set; + } + [JsonProperty(PropertyName = "buyerCountry")] + public string BuyerCountry + { + get; set; + } + [JsonProperty(PropertyName = "buyerZip")] + public string BuyerZip + { + get; set; + } + [JsonProperty(PropertyName = "buyerState")] + public string BuyerState + { + get; set; + } + [JsonProperty(PropertyName = "buyerCity")] + public string BuyerCity + { + get; set; + } + [JsonProperty(PropertyName = "buyerAddress2")] + public string BuyerAddress2 + { + get; set; + } + [JsonProperty(PropertyName = "buyerAddress1")] + public string BuyerAddress1 + { + get; set; + } + + [JsonProperty(PropertyName = "buyerPhone")] + public string BuyerPhone + { + get; set; + } + } + class ProductInformation + { + [JsonProperty(PropertyName = "itemDesc")] + public string ItemDesc + { + get; set; + } + [JsonProperty(PropertyName = "itemCode")] + public string ItemCode + { + get; set; + } + [JsonProperty(PropertyName = "physical")] + public bool Physical + { + get; set; + } + + [JsonProperty(PropertyName = "price")] + public decimal Price + { + get; set; + } + + [JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)] + public decimal TaxIncluded + { + get; set; + } + + [JsonProperty(PropertyName = "currency")] + public string Currency + { + get; set; + } + } [JsonIgnore] public BTCPayNetworkProvider Networks { get; set; } public const int InternalTagSupport_Version = 1; - public const int Lastest_Version = 1; + public const int GreenfieldInvoices_Version = 2; + public const int Lastest_Version = 2; public int Version { get; set; } public string Id { @@ -120,11 +219,6 @@ namespace BTCPayServer.Services.Invoices get; set; } - public string OrderId - { - get; set; - } - public SpeedPolicy SpeedPolicy { get; set; @@ -148,20 +242,20 @@ namespace BTCPayServer.Services.Invoices { get; set; } - public ProductInformation ProductInformation - { - get; set; - } - public BuyerInformation BuyerInformation - { - get; set; - } - public string PosData + + public InvoiceMetadata Metadata { get; set; } + + public decimal Price { get; set; } + public string Currency { get; set; } + + [JsonExtensionData] + public IDictionary AdditionalData { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public HashSet InternalTags { get; set; } = new HashSet(); @@ -300,7 +394,7 @@ namespace BTCPayServer.Services.Invoices private Uri FillPlaceholdersUri(string v) { - var uriStr = (v ?? string.Empty).Replace("{OrderId}", System.Web.HttpUtility.UrlEncode(OrderId) ?? "", StringComparison.OrdinalIgnoreCase) + var uriStr = (v ?? string.Empty).Replace("{OrderId}", System.Web.HttpUtility.UrlEncode(Metadata.OrderId) ?? "", StringComparison.OrdinalIgnoreCase) .Replace("{InvoiceId}", System.Web.HttpUtility.UrlEncode(Id) ?? "", StringComparison.OrdinalIgnoreCase); if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) return uri; @@ -383,8 +477,8 @@ namespace BTCPayServer.Services.Invoices { Id = Id, StoreId = StoreId, - OrderId = OrderId, - PosData = PosData, + OrderId = Metadata.OrderId, + PosData = Metadata.PosData, CurrentTime = DateTimeOffset.UtcNow, InvoiceTime = InvoiceTime, ExpirationTime = ExpirationTime, @@ -392,7 +486,7 @@ namespace BTCPayServer.Services.Invoices Status = StatusString, ExceptionStatus = ExceptionStatus == InvoiceExceptionStatus.None ? new JValue(false) : new JValue(ExceptionStatusString), #pragma warning restore CS0618 // Type or member is obsolete - Currency = ProductInformation.Currency, + Currency = Currency, Flags = new Flags() { Refundable = Refundable }, PaymentSubtotals = new Dictionary(), PaymentTotals = new Dictionary(), @@ -415,7 +509,7 @@ namespace BTCPayServer.Services.Invoices var address = details?.GetPaymentDestination(); var exrates = new Dictionary { - { ProductInformation.Currency, cryptoInfo.Rate } + { Currency, cryptoInfo.Rate } }; cryptoInfo.CryptoCode = cryptoCode; @@ -465,7 +559,7 @@ namespace BTCPayServer.Services.Invoices { var minerInfo = new MinerFeeInfo(); minerInfo.TotalFee = accounting.NetworkFee.Satoshi; - minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate + minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod) details).FeeRate .GetFee(1).Satoshi; dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); cryptoInfo.PaymentUrls = new InvoicePaymentUrls() @@ -502,29 +596,27 @@ namespace BTCPayServer.Services.Invoices //dto.AmountPaid dto.MinerFees & dto.TransactionCurrency are not supported by btcpayserver as we have multi currency payment support per invoice - Populate(ProductInformation, dto); + dto.ItemCode = Metadata.ItemCode; + dto.ItemDesc = Metadata.ItemDesc; + dto.TaxIncluded = Metadata.TaxIncluded ?? 0m; + dto.Price = Price; + dto.Currency = Currency; dto.Buyer = new JObject(); - dto.Buyer.Add(new JProperty("name", BuyerInformation.BuyerName)); - dto.Buyer.Add(new JProperty("address1", BuyerInformation.BuyerAddress1)); - dto.Buyer.Add(new JProperty("address2", BuyerInformation.BuyerAddress2)); - dto.Buyer.Add(new JProperty("locality", BuyerInformation.BuyerCity)); - dto.Buyer.Add(new JProperty("region", BuyerInformation.BuyerState)); - dto.Buyer.Add(new JProperty("postalCode", BuyerInformation.BuyerZip)); - dto.Buyer.Add(new JProperty("country", BuyerInformation.BuyerCountry)); - dto.Buyer.Add(new JProperty("phone", BuyerInformation.BuyerPhone)); - dto.Buyer.Add(new JProperty("email", string.IsNullOrWhiteSpace(BuyerInformation.BuyerEmail) ? RefundMail : BuyerInformation.BuyerEmail)); + dto.Buyer.Add(new JProperty("name", Metadata.BuyerName)); + dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1)); + dto.Buyer.Add(new JProperty("address2", Metadata.BuyerAddress2)); + dto.Buyer.Add(new JProperty("locality", Metadata.BuyerCity)); + dto.Buyer.Add(new JProperty("region", Metadata.BuyerState)); + dto.Buyer.Add(new JProperty("postalCode", Metadata.BuyerZip)); + dto.Buyer.Add(new JProperty("country", Metadata.BuyerCountry)); + dto.Buyer.Add(new JProperty("phone", Metadata.BuyerPhone)); + dto.Buyer.Add(new JProperty("email", string.IsNullOrWhiteSpace(Metadata.BuyerEmail) ? RefundMail : Metadata.BuyerEmail)); dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for dto.Guid = Guid.NewGuid().ToString(); return dto; } - private void Populate(TFrom from, TDest dest) - { - var str = JsonConvert.SerializeObject(from); - JsonConvert.PopulateObject(str, dest); - } - internal bool Support(PaymentMethodId paymentMethodId) { var rates = GetPaymentMethods(); @@ -602,26 +694,65 @@ namespace BTCPayServer.Services.Invoices { return new InvoiceState(Status, ExceptionStatus); } + + /// + /// Invoice version < 1 were saving metadata directly under the InvoiceEntity + /// object. But in version > 2, the metadata is saved under the InvoiceEntity.Metadata object + /// This method is extracting metadata from the InvoiceEntity of version < 1 invoices and put them in InvoiceEntity.Metadata. + /// + internal void MigrateLegacyInvoice() + { + T TryParseMetadata(string field) where T : class + { + if (AdditionalData.TryGetValue(field, out var token) && token is JObject obj) + { + return obj.ToObject(); + } + return null; + } + if (TryParseMetadata("buyerInformation") is BuyerInformation buyerInformation && + TryParseMetadata("productInformation") is ProductInformation productInformation) + { + var wellknown = new InvoiceMetadata() + { + BuyerAddress1 = buyerInformation.BuyerAddress1, + BuyerAddress2 = buyerInformation.BuyerAddress2, + BuyerCity = buyerInformation.BuyerCity, + BuyerCountry = buyerInformation.BuyerCountry, + BuyerEmail = buyerInformation.BuyerEmail, + BuyerName = buyerInformation.BuyerName, + BuyerPhone = buyerInformation.BuyerPhone, + BuyerState = buyerInformation.BuyerState, + BuyerZip = buyerInformation.BuyerZip, + ItemCode = productInformation.ItemCode, + ItemDesc = productInformation.ItemDesc, + Physical = productInformation.Physical, + TaxIncluded = productInformation.TaxIncluded + }; + if (AdditionalData.TryGetValue("posData", out var token) && + token is JValue val && + val.Type == JTokenType.String) + { + wellknown.PosData = val.Value(); + } + if (AdditionalData.TryGetValue("orderId", out var token2) && + token2 is JValue val2 && + val2.Type == JTokenType.String) + { + wellknown.OrderId = val2.Value(); + } + Metadata = wellknown; + Currency = productInformation.Currency; + Price = productInformation.Price; + } + else + { + throw new InvalidOperationException("Not a legacy invoice"); + } + } } - public enum InvoiceStatus - { - New, - Paid, - Expired, - Invalid, - Complete, - Confirmed - } - public enum InvoiceExceptionStatus - { - None, - PaidLate, - PaidPartial, - Marked, - Invalid, - PaidOver - } + public class InvoiceState { static readonly Dictionary _StringToInvoiceStatus; @@ -853,7 +984,7 @@ namespace BTCPayServer.Services.Invoices paymentPredicate = paymentPredicate ?? new Func((p) => true); var paymentMethods = ParentEntity.GetPaymentMethods(); - var totalDue = ParentEntity.ProductInformation.Price / Rate; + var totalDue = ParentEntity.Price / Rate; var paid = 0m; var cryptoPaid = 0.0m; diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 8bd644c52..b5d479b80 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Payments; @@ -13,7 +14,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NBitcoin; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Encoders = NBitcoin.DataEncoders.Encoders; +using InvoiceData = BTCPayServer.Data.InvoiceData; namespace BTCPayServer.Services.Invoices { @@ -36,11 +39,12 @@ namespace BTCPayServer.Services.Invoices } private readonly ApplicationDbContextFactory _ContextFactory; + private readonly EventAggregator _eventAggregator; private readonly BTCPayNetworkProvider _Networks; private readonly CustomThreadPool _IndexerThread; public InvoiceRepository(ApplicationDbContextFactory contextFactory, string dbreezePath, - BTCPayNetworkProvider networks) + BTCPayNetworkProvider networks, EventAggregator eventAggregator) { int retryCount = 0; retry: @@ -52,6 +56,7 @@ retry: _IndexerThread = new CustomThreadPool(1, "Invoice Indexer"); _ContextFactory = contextFactory; _Networks = networks; + _eventAggregator = eventAggregator; } public InvoiceEntity CreateNewInvoice() @@ -61,6 +66,7 @@ retry: Networks = _Networks, Version = InvoiceEntity.Lastest_Version, InvoiceTime = DateTimeOffset.UtcNow, + Metadata = new InvoiceMetadata() }; } @@ -158,11 +164,11 @@ retry: Id = invoice.Id, Created = invoice.InvoiceTime, Blob = ToBytes(invoice, null), - OrderId = invoice.OrderId, + OrderId = invoice.Metadata.OrderId, #pragma warning disable CS0618 // Type or member is obsolete Status = invoice.StatusString, #pragma warning restore CS0618 // Type or member is obsolete - ItemCode = invoice.ProductInformation.ItemCode, + ItemCode = invoice.Metadata.ItemCode, CustomerEmail = invoice.RefundMail, Archived = false }); @@ -194,10 +200,9 @@ retry: textSearch.Add(invoice.Id); textSearch.Add(invoice.InvoiceTime.ToString(CultureInfo.InvariantCulture)); - textSearch.Add(invoice.ProductInformation.Price.ToString(CultureInfo.InvariantCulture)); - textSearch.Add(invoice.OrderId); - textSearch.Add(ToString(invoice.BuyerInformation, null)); - textSearch.Add(ToString(invoice.ProductInformation, null)); + textSearch.Add(invoice.Price.ToString(CultureInfo.InvariantCulture)); + textSearch.Add(invoice.Metadata.OrderId); + textSearch.Add(ToString(invoice.Metadata, null)); textSearch.Add(invoice.StoreId); AddToTextSearch(invoice.Id, textSearch.ToArray()); @@ -427,42 +432,68 @@ retry: } } - public async Task ToggleInvoiceArchival(string invoiceId, bool archived) + public async Task ToggleInvoiceArchival(string invoiceId, bool archived, string storeId = null) { using (var context = _ContextFactory.CreateContext()) { var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); - if (invoiceData == null || invoiceData.Archived == archived) + if (invoiceData == null || invoiceData.Archived == archived || + (storeId != null && + !invoiceData.StoreDataId.Equals(storeId, StringComparison.InvariantCultureIgnoreCase))) return; invoiceData.Archived = archived; await context.SaveChangesAsync().ConfigureAwait(false); } } - public async Task UpdatePaidInvoiceToInvalid(string invoiceId) + public async Task MarkInvoiceStatus(string invoiceId, InvoiceStatus status) { using (var context = _ContextFactory.CreateContext()) { - var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); - if (invoiceData == null || !invoiceData.GetInvoiceState().CanMarkInvalid()) - return; - invoiceData.Status = "invalid"; - invoiceData.ExceptionStatus = "marked"; - await context.SaveChangesAsync().ConfigureAwait(false); - } - } - public async Task UpdatePaidInvoiceToComplete(string invoiceId) - { - using (var context = _ContextFactory.CreateContext()) - { - var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); - if (invoiceData == null || !invoiceData.GetInvoiceState().CanMarkComplete()) - return; - invoiceData.Status = "complete"; - invoiceData.ExceptionStatus = "marked"; - await context.SaveChangesAsync().ConfigureAwait(false); + var invoiceData = await GetInvoiceRaw(invoiceId); + if (invoiceData == null) + { + return false; + } + + context.Attach(invoiceData); + string eventName; + switch (status) + { + case InvoiceStatus.Complete: + if (!invoiceData.GetInvoiceState().CanMarkComplete()) + { + return false; + } + + eventName = InvoiceEvent.MarkedCompleted; + break; + case InvoiceStatus.Invalid: + if (!invoiceData.GetInvoiceState().CanMarkInvalid()) + { + return false; + } + eventName = InvoiceEvent.MarkedInvalid; + break; + default: + return false; + } + + invoiceData.Status = status.ToString().ToLowerInvariant(); + invoiceData.ExceptionStatus = InvoiceExceptionStatus.Marked.ToString().ToLowerInvariant(); + _eventAggregator.Publish(new InvoiceEvent(ToEntity(invoiceData), eventName)); + await context.SaveChangesAsync(); } + + return true; } + public async Task GetInvoice(string id, bool inludeAddressData = false) + { + var res = await GetInvoiceRaw(id, inludeAddressData); + return res == null ? null : ToEntity(res); + } + + private async Task GetInvoiceRaw(string id, bool inludeAddressData = false) { using (var context = _ContextFactory.CreateContext()) { @@ -478,7 +509,7 @@ retry: if (invoice == null) return null; - return ToEntity(invoice); + return invoice; } } @@ -525,10 +556,9 @@ retry: { entity.Events = invoice.Events.OrderBy(c => c.Timestamp).ToList(); } - - if (!string.IsNullOrEmpty(entity.RefundMail) && string.IsNullOrEmpty(entity.BuyerInformation.BuyerEmail)) + if (!string.IsNullOrEmpty(entity.RefundMail) && string.IsNullOrEmpty(entity.Metadata.BuyerEmail)) { - entity.BuyerInformation.BuyerEmail = entity.RefundMail; + entity.Metadata.BuyerEmail = entity.RefundMail; } entity.Archived = invoice.Archived; return entity; diff --git a/BTCPayServer/Views/Invoice/Invoice.cshtml b/BTCPayServer/Views/Invoice/Invoice.cshtml index eaaaa0cc4..f1dfdd4f4 100644 --- a/BTCPayServer/Views/Invoice/Invoice.cshtml +++ b/BTCPayServer/Views/Invoice/Invoice.cshtml @@ -1,4 +1,4 @@ -@model InvoiceDetailsModel +@model InvoiceDetailsModel @{ ViewData["Title"] = "Invoice " + Model.Id; } @@ -93,7 +93,7 @@ Order Id - @Model.OrderId + @Model.TypedMetadata.OrderId Total fiat due @@ -114,39 +114,39 @@ - + - + - + - + - + - + - + - + - +
Name@Model.BuyerInformation.BuyerName@Model.TypedMetadata.BuyerName
Email@Model.BuyerInformation.BuyerEmail@Model.TypedMetadata.BuyerEmail
Phone@Model.BuyerInformation.BuyerPhone@Model.TypedMetadata.BuyerPhone
Address 1@Model.BuyerInformation.BuyerAddress1@Model.TypedMetadata.BuyerAddress1
Address 2@Model.BuyerInformation.BuyerAddress2@Model.TypedMetadata.BuyerAddress2
City@Model.BuyerInformation.BuyerCity@Model.TypedMetadata.BuyerCity
State@Model.BuyerInformation.BuyerState@Model.TypedMetadata.BuyerState
Country@Model.BuyerInformation.BuyerCountry@Model.TypedMetadata.BuyerCountry
Zip@Model.BuyerInformation.BuyerZip@Model.TypedMetadata.BuyerZip
@if (Model.PosData.Count == 0) @@ -155,11 +155,11 @@ - + - + @@ -182,11 +182,11 @@
Item code@Model.ProductInformation.ItemCode@Model.TypedMetadata.ItemCode
Item Description@Model.ProductInformation.ItemDesc@Model.TypedMetadata.ItemDesc
Price
- + - + diff --git a/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml b/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml index 4919520d1..719f63445 100644 --- a/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml +++ b/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml @@ -1,4 +1,5 @@ -@model (InvoiceDetailsModel Invoice, bool ShowAddress) +@using BTCPayServer.Client.Models +@model (InvoiceDetailsModel Invoice, bool ShowAddress) @{ var invoice = Model.Invoice; } @inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json new file mode 100644 index 000000000..aee6d8cf9 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json @@ -0,0 +1,576 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/invoices": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get invoices", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to query", + "schema": { + "type": "string" + } + } + ], + "description": "View information about the existing invoices", + "operationId": "Invoices_GetInvoices", + "responses": { + "200": { + "description": "list of invoices", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceDataList" + } + } + } + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canviewinvoices" + ], + "Basic": [] + } + ] + }, + "post": { + "tags": [ + "Invoices" + ], + "summary": "Create a new invoice", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to query", + "schema": { + "type": "string" + } + } + ], + "description": "Create a new invoice", + "operationId": "Invoices_CreateInvoice", + "responses": { + "200": { + "description": "Information about the new invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceData" + } + } + } + }, + "400": { + "description": "A list of errors that occurred when creating the invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to add new invoices" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateInvoiceRequest" + } + } + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.cancreateinvoice" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/invoices/{invoiceId}": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Get invoice", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "invoiceId", + "in": "path", + "required": true, + "description": "The invoice to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "View information about the specified invoice", + "operationId": "Invoices_GetInvoice", + "responses": { + "200": { + "description": "specified invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified invoie" + }, + "404": { + "description": "The key is not found for this invoice" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canviewinvoices" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Invoices" + ], + "summary": "Archive invoice", + "description": "Archives the specified invoice.", + "operationId": "Invoices_ArchiveInvoice", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store the invoice belongs to", + "schema": { + "type": "string" + } + }, + { + "name": "invoiceId", + "in": "path", + "required": true, + "description": "The invoice to remove", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The invoice has been archived" + }, + "400": { + "description": "A list of errors that occurred when archiving the invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to archive the specified invoice" + }, + "404": { + "description": "The key is not found for this invoice" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/invoices/{invoiceId}/status": { + "post": { + "tags": [ + "Invoices" + ], + "summary": "Mark invoice status", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to query", + "schema": { + "type": "string" + } + }, + { + "name": "invoiceId", + "in": "path", + "required": true, + "description": "The invoice to update", + "schema": { + "type": "string" + } + } + ], + "description": "Mark an invoice as invalid or completed.", + "operationId": "Invoices_MarkInvoiceStatus", + "responses": { + "200": { + "description": "The updated invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceData" + } + } + } + }, + "400": { + "description": "A list of errors that occurred when updating the invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to update the invoice" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkInvoiceStatusRequest" + } + } + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive": { + "post": { + "tags": [ + "Invoices" + ], + "summary": "Unarchive invoice", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to query", + "schema": { + "type": "string" + } + }, + { + "name": "invoiceId", + "in": "path", + "required": true, + "description": "The invoice to update", + "schema": { + "type": "string" + } + } + ], + "description": "Unarchive an invoice", + "operationId": "Invoices_UnarchiveInvoice", + "responses": { + "200": { + "description": "The unarchived invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceData" + } + } + } + }, + "400": { + "description": "A list of errors that occurred when updating the invoice", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to update the invoice" + } + }, + "security": [ + { + "API Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "InvoiceDataList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvoiceData" + } + }, + "MarkInvoiceStatusRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "nullable": false, + "description": "Mark an invoice as completed or invalid.", + "oneOf": [ + { + "$ref": "#/components/schemas/InvoiceStatusMark" + } + ] + } + } + }, + "AddCustomerEmailRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "nullable": false, + "description": "Sets the customer email, if it was not set before." + } + } + }, + "InvoiceStatusMark": { + "type": "string", + "description": "", + "x-enumNames": [ + "Invalid", + "Complete" + ], + "enum": [ + "Invalid", + "Complete" + ] + }, + "InvoiceStatus": { + "type": "string", + "description": "", + "x-enumNames": [ + "New", + "Paid", + "Expired", + "Invalid", + "Complete", + "Confirmed" + ], + "enum": [ + "New", + "Paid", + "Expired", + "Invalid", + "Complete", + "Confirmed" + ] + }, + "InvoiceAdditionalStatus": { + "type": "string", + "description": "An additional status that describes why an invoice is in its current status.", + "x-enumNames": [ + "None", + "PaidLate", + "PaidPartial", + "Marked", + "Invalid", + "PaidOver" + ], + "enum": [ + "None", + "PaidLate", + "PaidPartial", + "Marked", + "Invalid", + "PaidOver" + ] + }, + "InvoiceData": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateInvoiceRequest" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The identifier of the invoice" + }, + "createdTime": { + "type": "number", + "format": "int64", + "description": "The creation time of the invoice" + }, + "expirationTime": { + "type": "number", + "format": "int64", + "description": "The expiration time of the invoice" + }, + "monitoringTime": { + "type": "number", + "format": "int64", + "description": "The monitoring time of the invoice" + }, + "status": { + "$ref": "#/components/schemas/InvoiceStatus", + "description": "The status of the invoice" + }, + "additionalStatus": { + "$ref": "#/components/schemas/InvoiceAdditionalStatus", + "description": "a secondary status of the invoice" + } + } + } + ] + }, + "CreateInvoiceRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "amount": { + "type": "string", + "format": "decimal", + "description": "The amount of the invoice" + }, + "currency": { + "type": "string", + "nullable": true, + "description": "The currency the invoice will use" + }, + "metadata": { + "type": "string", + "nullable": true, + "description": "Additional information around the invoice that can be supplied." + }, + "checkout": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CheckoutOptions" + } + ], + "description": "Additional settings to customize the checkout flow" + } + } + }, + "CheckoutOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "speedPolicy": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/SpeedPolicy" + } + ], + "description": "This is a risk mitigation parameter for the merchant to configure how they want to fulfill orders depending on the number of block confirmations for the transaction made by the consumer on the selected cryptocurrency" + }, + "paymentMethods": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "A specific set of payment methods to use for this invoice (ie. BTC, BTC-LightningNetwork). By default, select all payment methods activated in the store." + }, + "expirationMinutes": { + "type": "integer", + "nullable": true, + "description": "The number of minutes after which an invoice becomes expired. Default to the store's settings. (The default store settings is 15)" + }, + "monitoringMinutes": { + "type": "integer", + "nullable": true, + "description": "The number of minutes after an invoice expired after which we are still monitoring for incoming payments. Default to the store's settings. (The default store settings is 1440, 1 day)" + }, + "paymentTolerance": { + "type": "number", + "format": "double", + "nullable": true, + "minimum": 0, + "maximum": 100, + "description": "A percentage determining whether to count the invoice as paid when the invoice is paid within the specified margin of error. Default to the store's settings. (The default store settings is 100)" + } + } + }, + "SpeedPolicy": { + "type": "string", + "description": "", + "x-enumNames": [ + "HighSpeed", + "MediumSpeed", + "LowSpeed", + "LowMediumSpeed" + ], + "enum": [ + "HighSpeed", + "MediumSpeed", + "LowSpeed", + "LowMediumSpeed" + ] + } + } + }, + "tags": [ + { + "name": "Invoices" + } + ] +} diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.payment-requests.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.payment-requests.json index 55c936206..9bbde2c4b 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.payment-requests.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.payment-requests.json @@ -122,7 +122,7 @@ } ], "description": "View information about the specified payment request", - "operationId": "PaymentRequests_GetPaymentRequests", + "operationId": "PaymentRequests_GetPaymentRequest", "responses": { "200": { "description": "specified payment request", @@ -218,7 +218,7 @@ "name": "paymentRequestId", "in": "path", "required": true, - "description": "The payment request to remove", + "description": "The payment request to update", "schema": { "type": "string" } } ],
Item code@Model.ProductInformation.ItemCode@Model.TypedMetadata.ItemCode
Item Description@Model.ProductInformation.ItemDesc@Model.TypedMetadata.ItemDesc
Price