diff --git a/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs b/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs index 547d8d349..b7b0135d9 100644 --- a/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs +++ b/BTCPayServer.Client/BTCPayServerClient.PaymentRequests.cs @@ -9,18 +9,18 @@ namespace BTCPayServer.Client; public partial class BTCPayServerClient { - public virtual async Task> GetPaymentRequests(string storeId, + public virtual async Task> GetPaymentRequests(string storeId, bool includeArchived = false, CancellationToken token = default) { - return await SendHttpRequest>($"api/v1/stores/{storeId}/payment-requests", + return await SendHttpRequest>($"api/v1/stores/{storeId}/payment-requests", new Dictionary { { nameof(includeArchived), includeArchived } }, HttpMethod.Get, token); } - public virtual async Task GetPaymentRequest(string storeId, string paymentRequestId, + public virtual async Task GetPaymentRequest(string storeId, string paymentRequestId, CancellationToken token = default) { - return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", null, HttpMethod.Get, token); + return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", null, HttpMethod.Get, token); } public virtual async Task ArchivePaymentRequest(string storeId, string paymentRequestId, @@ -37,17 +37,17 @@ public partial class BTCPayServerClient return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}/pay", request, HttpMethod.Post, token); } - public virtual async Task CreatePaymentRequest(string storeId, - CreatePaymentRequestRequest request, CancellationToken token = default) + public virtual async Task CreatePaymentRequest(string storeId, + PaymentRequestBaseData request, CancellationToken token = default) { if (request == null) throw new ArgumentNullException(nameof(request)); - return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests", request, HttpMethod.Post, token); + return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests", request, HttpMethod.Post, token); } - public virtual async Task UpdatePaymentRequest(string storeId, string paymentRequestId, - UpdatePaymentRequestRequest request, CancellationToken token = default) + public virtual async Task UpdatePaymentRequest(string storeId, string paymentRequestId, + PaymentRequestBaseData request, CancellationToken token = default) { if (request == null) throw new ArgumentNullException(nameof(request)); - return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", request, HttpMethod.Put, token); + return await SendHttpRequest($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", request, HttpMethod.Put, token); } } diff --git a/BTCPayServer.Client/Models/CreatePaymentRequestRequest.cs b/BTCPayServer.Client/Models/CreatePaymentRequestRequest.cs deleted file mode 100644 index 17b64128d..000000000 --- a/BTCPayServer.Client/Models/CreatePaymentRequestRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace BTCPayServer.Client.Models -{ - public class CreatePaymentRequestRequest : PaymentRequestBaseData - { - } -} diff --git a/BTCPayServer.Client/Models/PaymentRequestBaseData.cs b/BTCPayServer.Client/Models/PaymentRequestBaseData.cs index 8f6c2ee60..fb1203a63 100644 --- a/BTCPayServer.Client/Models/PaymentRequestBaseData.cs +++ b/BTCPayServer.Client/Models/PaymentRequestBaseData.cs @@ -2,10 +2,18 @@ using System; using System.Collections.Generic; using BTCPayServer.JsonConverters; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; namespace BTCPayServer.Client.Models { + public enum PaymentRequestStatus + { + Pending, + Completed, + Expired, + Processing, + } public class PaymentRequestBaseData { public string StoreId { get; set; } @@ -19,11 +27,17 @@ namespace BTCPayServer.Client.Models public string Email { get; set; } public bool AllowCustomPaymentAmounts { get; set; } - [JsonExtensionData] - public IDictionary AdditionalData { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public PaymentRequestStatus Status { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset CreatedTime { get; set; } + public string Id { get; set; } + public bool Archived { get; set; } public string FormId { get; set; } - public JObject FormResponse { get; set; } + + [JsonExtensionData] + public IDictionary AdditionalData { get; set; } } } diff --git a/BTCPayServer.Client/Models/PaymentRequestData.cs b/BTCPayServer.Client/Models/PaymentRequestData.cs deleted file mode 100644 index 86a42f903..000000000 --- a/BTCPayServer.Client/Models/PaymentRequestData.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace BTCPayServer.Client.Models -{ - public class PaymentRequestData : PaymentRequestBaseData - { - [JsonConverter(typeof(StringEnumConverter))] - public PaymentRequestStatus Status { get; set; } - [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] - public DateTimeOffset CreatedTime { get; set; } - public string Id { get; set; } - public bool Archived { get; set; } - public enum PaymentRequestStatus - { - Pending = 0, - Completed = 1, - Expired = 2, - Processing = 3 - } - } -} diff --git a/BTCPayServer.Client/Models/UpdatePaymentRequestRequest.cs b/BTCPayServer.Client/Models/UpdatePaymentRequestRequest.cs deleted file mode 100644 index 5ae327ae9..000000000 --- a/BTCPayServer.Client/Models/UpdatePaymentRequestRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace BTCPayServer.Client.Models -{ - public class UpdatePaymentRequestRequest : PaymentRequestBaseData - { - } -} diff --git a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs index 6e7f74f3b..de40a0808 100644 --- a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs +++ b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs @@ -30,7 +30,7 @@ namespace BTCPayServer.Client.Models } [JsonProperty(Order = 2)] public string PaymentRequestId { get; set; } - [JsonProperty(Order = 3)] [JsonConverter(typeof(StringEnumConverter))]public PaymentRequestData.PaymentRequestStatus Status { get; set; } + [JsonProperty(Order = 3)] [JsonConverter(typeof(StringEnumConverter))]public PaymentRequestStatus Status { get; set; } } public abstract class StoreWebhookEvent : WebhookEvent diff --git a/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs b/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs index 01319150c..62dfb38ba 100644 --- a/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs +++ b/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Globalization; using System.Linq; using System.Reflection.Metadata; using System.Text; @@ -17,7 +18,7 @@ namespace BTCPayServer.Data public bool TryMigrate() { #pragma warning disable CS0618 // Type or member is obsolete - if (Blob is null && Blob2 is not null) + if (Blob is (null or { Length: 0 }) && Blob2 is not null && Currency is not null) return false; if (Blob2 is null) { @@ -28,11 +29,27 @@ namespace BTCPayServer.Data #pragma warning restore CS0618 // Type or member is obsolete var jobj = JObject.Parse(Blob2); // Fixup some legacy payment requests - if (jobj["expiryDate"].Type == JTokenType.Date) + if (jobj["expiryDate"]?.Type == JTokenType.Date) { - jobj["expiryDate"] = new JValue(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value())); - Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None); + var date = NBitcoin.Utils.UnixTimeToDateTime(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value())); + jobj.Remove("expiryDate"); + Expiry = date; } + else if (jobj["expiryDate"]?.Type == JTokenType.Integer) + { + var date = NBitcoin.Utils.UnixTimeToDateTime(jobj["expiryDate"].Value()); + jobj.Remove("expiryDate"); + Expiry = date; + } + Currency = jobj["currency"].Value(); + Amount = jobj["amount"] switch + { + JValue jv when jv.Type == JTokenType.Float => jv.Value(), + JValue jv when jv.Type == JTokenType.Integer => jv.Value(), + JValue jv when jv.Type == JTokenType.String && decimal.TryParse(jv.Value(), CultureInfo.InvariantCulture, out var d) => d, + _ => 0m + }; + Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None); return true; } } diff --git a/BTCPayServer.Data/Data/PaymentRequestData.cs b/BTCPayServer.Data/Data/PaymentRequestData.cs index 588e64f5b..bbe194905 100644 --- a/BTCPayServer.Data/Data/PaymentRequestData.cs +++ b/BTCPayServer.Data/Data/PaymentRequestData.cs @@ -8,12 +8,15 @@ namespace BTCPayServer.Data { public string Id { get; set; } public DateTimeOffset Created { get; set; } + public DateTimeOffset? Expiry { get; set; } public string StoreDataId { get; set; } public bool Archived { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } public StoreData StoreData { get; set; } - public Client.Models.PaymentRequestData.PaymentRequestStatus Status { get; set; } + public Client.Models.PaymentRequestStatus Status { get; set; } [Obsolete("Use Blob2 instead")] public byte[] Blob { get; set; } @@ -35,6 +38,9 @@ namespace BTCPayServer.Data builder.Entity() .Property(o => o.Blob2) .HasColumnType("JSONB"); + builder.Entity() + .Property(p => p.Status) + .HasConversion(); } } } diff --git a/BTCPayServer.Data/Migrations/20250407133937_pr_expiry.cs b/BTCPayServer.Data/Migrations/20250407133937_pr_expiry.cs new file mode 100644 index 000000000..0154396be --- /dev/null +++ b/BTCPayServer.Data/Migrations/20250407133937_pr_expiry.cs @@ -0,0 +1,55 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250407133937_pr_expiry")] + public partial class pr_expiry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Expiry", + table: "PaymentRequests", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "Amount", + table: "PaymentRequests", + type: "numeric", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Currency", + table: "PaymentRequests", + type: "text", + nullable: true); + migrationBuilder.Sql(""" + ALTER TABLE "PaymentRequests" ADD COLUMN "StatusNew" TEXT; + UPDATE "PaymentRequests" SET "StatusNew" = CASE + WHEN "Status" = 0 THEN 'Pending' + WHEN "Status" = 1 THEN 'Processing' + WHEN "Status" = 2 THEN 'Completed' + WHEN "Status" = 3 THEN 'Expired' + ELSE NULL + END; + ALTER TABLE "PaymentRequests" DROP COLUMN "Status"; + ALTER TABLE "PaymentRequests" RENAME COLUMN "StatusNew" TO "Status"; + ALTER TABLE "PaymentRequests" ALTER COLUMN "Status" SET NOT NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index a11dab6d2..00fa37212 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace BTCPayServer.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("ProductVersion", "8.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -521,6 +521,9 @@ namespace BTCPayServer.Migrations b.Property("Id") .HasColumnType("text"); + b.Property("Amount") + .HasColumnType("numeric"); + b.Property("Archived") .HasColumnType("boolean"); @@ -535,8 +538,15 @@ namespace BTCPayServer.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValue(new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - b.Property("Status") - .HasColumnType("integer"); + b.Property("Currency") + .HasColumnType("text"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); b.Property("StoreDataId") .HasColumnType("text"); diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 4c6cd1a73..b339d56d8 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NBitcoin; using NBXplorer; diff --git a/BTCPayServer.Tests/DatabaseTester.cs b/BTCPayServer.Tests/DatabaseTester.cs index 9b373d3a2..7e02f13f3 100644 --- a/BTCPayServer.Tests/DatabaseTester.cs +++ b/BTCPayServer.Tests/DatabaseTester.cs @@ -19,7 +19,7 @@ namespace BTCPayServer.Tests public class DatabaseTester { private readonly ILoggerFactory _loggerFactory; - private readonly string dbname; + public readonly string dbname; private string[] notAppliedMigrations; public DatabaseTester(ILog log, ILoggerFactory loggerFactory) diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 8e406abf5..1f39425f0 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2030,25 +2030,25 @@ namespace BTCPayServer.Tests //validation errors await AssertValidationError(new[] { "Amount" }, async () => { - await client.CreatePaymentRequest(user.StoreId, new CreatePaymentRequestRequest() { Title = "A" }); + await client.CreatePaymentRequest(user.StoreId, new() { Title = "A" }); }); await AssertValidationError(new[] { "Amount" }, async () => { await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Title = "A", Currency = "BTC", Amount = 0 }); + new() { Title = "A", Currency = "BTC", Amount = 0 }); }); await AssertValidationError(new[] { "Currency" }, async () => { await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Title = "A", Currency = "helloinvalid", Amount = 1 }); + new() { Title = "A", Currency = "helloinvalid", Amount = 1 }); }); await AssertHttpError(403, async () => { await viewOnly.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Title = "A", Currency = "helloinvalid", Amount = 1 }); + new() { Title = "A", Currency = "helloinvalid", Amount = 1 }); }); var newPaymentRequest = await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Title = "A", Currency = "USD", Amount = 1 }); + new() { Title = "A", Currency = "USD", Amount = 1 }); //list payment request var paymentRequests = await viewOnly.GetPaymentRequests(user.StoreId); @@ -2063,7 +2063,7 @@ namespace BTCPayServer.Tests Assert.Equal(newPaymentRequest.StoreId, user.StoreId); //update payment request - var updateRequest = JObject.FromObject(paymentRequest).ToObject(); + var updateRequest = paymentRequest; updateRequest.Title = "B"; await AssertHttpError(403, async () => { @@ -2086,7 +2086,7 @@ namespace BTCPayServer.Tests //let's test some payment stuff with the UI await user.RegisterDerivationSchemeAsync("BTC"); var paymentTestPaymentRequest = await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Amount = 0.1m, Currency = "BTC", Title = "Payment test title" }); + new() { Amount = 0.1m, Currency = "BTC", Title = "Payment test title" }); var invoiceId = Assert.IsType(Assert.IsType(await user.GetController() .PayPaymentRequest(paymentTestPaymentRequest.Id, false)).Value); @@ -2105,37 +2105,37 @@ namespace BTCPayServer.Tests { Assert.Equal(Invoice.STATUS_PAID, (await user.BitPay.GetInvoiceAsync(invoiceId)).Status); if (!partialPayment) - Assert.Equal(PaymentRequestData.PaymentRequestStatus.Processing, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status); + Assert.Equal(PaymentRequestStatus.Processing, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status); }); await tester.ExplorerNode.GenerateAsync(1); await TestUtils.EventuallyAsync(async () => { Assert.Equal(Invoice.STATUS_COMPLETE, (await user.BitPay.GetInvoiceAsync(invoiceId)).Status); if (!partialPayment) - Assert.Equal(PaymentRequestData.PaymentRequestStatus.Completed, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status); + Assert.Equal(PaymentRequestStatus.Completed, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status); }); } await Pay(invoiceId); //Same thing, but with the API paymentTestPaymentRequest = await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Amount = 0.1m, Currency = "BTC", Title = "Payment test title" }); + new() { Amount = 0.1m, Currency = "BTC", Title = "Payment test title" }); var paidPrId = paymentTestPaymentRequest.Id; var invoiceData = await client.PayPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new PayPaymentRequestRequest()); await Pay(invoiceData.Id); // Can't update amount once invoice has been created - await AssertValidationError(new[] { "Amount" }, () => client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new UpdatePaymentRequestRequest() + await AssertValidationError(new[] { "Amount" }, () => client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new() { Amount = 294m })); // Let's tests some unhappy path paymentTestPaymentRequest = await client.CreatePaymentRequest(user.StoreId, - new CreatePaymentRequestRequest() { Amount = 0.1m, AllowCustomPaymentAmounts = false, Currency = "BTC", Title = "Payment test title" }); + new() { Amount = 0.1m, AllowCustomPaymentAmounts = false, Currency = "BTC", Title = "Payment test title" }); await AssertValidationError(new[] { "Amount" }, () => client.PayPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new PayPaymentRequestRequest() { Amount = -0.04m })); await AssertValidationError(new[] { "Amount" }, () => client.PayPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new PayPaymentRequestRequest() { Amount = 0.04m })); - await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new UpdatePaymentRequestRequest() + await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new() { Amount = 0.1m, AllowCustomPaymentAmounts = true, @@ -2148,7 +2148,7 @@ namespace BTCPayServer.Tests var firstPaymentId = invoiceData.Id; await AssertAPIError("archived", () => client.PayPaymentRequest(user.StoreId, archivedPrId, new PayPaymentRequestRequest())); - await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new UpdatePaymentRequestRequest() + await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new() { Amount = 0.1m, AllowCustomPaymentAmounts = true, @@ -2160,7 +2160,7 @@ namespace BTCPayServer.Tests await AssertAPIError("expired", () => client.PayPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new PayPaymentRequestRequest())); await AssertAPIError("already-paid", () => client.PayPaymentRequest(user.StoreId, paidPrId, new PayPaymentRequestRequest())); - await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new UpdatePaymentRequestRequest() + await client.UpdatePaymentRequest(user.StoreId, paymentTestPaymentRequest.Id, new() { Amount = 0.1m, AllowCustomPaymentAmounts = true, diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 645266b0d..0485828db 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -72,12 +72,11 @@ using Xunit; using Xunit.Abstractions; using Xunit.Sdk; using CreateInvoiceRequest = BTCPayServer.Client.Models.CreateInvoiceRequest; -using CreatePaymentRequestRequest = BTCPayServer.Client.Models.CreatePaymentRequestRequest; using MarkPayoutRequest = BTCPayServer.Client.Models.MarkPayoutRequest; -using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData; using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel; using Microsoft.Extensions.Caching.Memory; using PosViewType = BTCPayServer.Client.Models.PosViewType; +using BTCPayServer.PaymentRequest; namespace BTCPayServer.Tests { @@ -2087,7 +2086,7 @@ namespace BTCPayServer.Tests await user.AssertHasWebhookEvent(WebhookEventType.InvoiceInvalid, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId)); //payment request webhook test - var pr = await client.CreatePaymentRequest(user.StoreId, new CreatePaymentRequestRequest() + var pr = await client.CreatePaymentRequest(user.StoreId, new () { Amount = 100m, Currency = "USD", @@ -2100,7 +2099,7 @@ namespace BTCPayServer.Tests }); await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestCreated, (WebhookPaymentRequestEvent x)=> Assert.Equal(pr.Id, x.PaymentRequestId)); pr = await client.UpdatePaymentRequest(user.StoreId, pr.Id, - new UpdatePaymentRequestRequest() { Title = "test pr updated", Amount = 100m, + new() { Title = "test pr updated", Amount = 100m, Currency = "USD", //TODO: this is a bug, we should not have these props in create request StoreId = user.StoreId, @@ -2113,7 +2112,7 @@ namespace BTCPayServer.Tests await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Settled}); await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestStatusChanged, (WebhookPaymentRequestEvent x)=> { - Assert.Equal(PaymentRequestData.PaymentRequestStatus.Completed, x.Status); + Assert.Equal(PaymentRequestStatus.Completed, x.Status); Assert.Equal(pr.Id, x.PaymentRequestId); }); await client.ArchivePaymentRequest(user.StoreId, pr.Id); @@ -2835,6 +2834,128 @@ namespace BTCPayServer.Tests Assert.Equal("coingecko", b.PreferredExchange); } + [Fact(Timeout = LongRunningTestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanMigratePaymentRequests() + { + var tester = CreateDBTester(); + await tester.MigrateUntil("20250407133937_pr_expiry"); + using var ctx = tester.CreateContext(); + var conn = ctx.Database.GetDbConnection(); + await conn.ExecuteAsync(""" + INSERT INTO "Stores" ("Id", "SpeedPolicy") VALUES ('7Nefq9u8DDYL56HAFskWdHNQ9ZCdfbkVWkH4xYhfzxiB', 0); + INSERT INTO "PaymentRequests" ( + "Id", "StoreDataId", "Status", "Blob", "Created", "Archived", "Blob2" + ) VALUES ( + '03463aab-844e-4d60-872f-26310b856131', + '7Nefq9u8DDYL56HAFskWdHNQ9ZCdfbkVWkH4xYhfzxiB', + 2, + NULL, + '2024-03-21 23:15:26.356677+00', + FALSE, + '{"email": "f.f@gmail.com", "title": "Online", "amount": 0.001, "formId": null, "storeId": null, "currency": "USD", "expiryDate": 1711066440, "description": null, "embeddedCSS": null, "formResponse": null, "customCSSLink": null, "allowCustomPaymentAmounts": false}' + ); + + INSERT INTO "PaymentRequests" ( + "Id", "StoreDataId", "Status", "Blob", "Created", "Archived", "Blob2" + ) VALUES ( + 'other', + '7Nefq9u8DDYL56HAFskWdHNQ9ZCdfbkVWkH4xYhfzxiB', + 1, + NULL, + '2024-03-21 23:15:26.356677+00', + FALSE, + '{"email": "z.f@gmail.com", "title": "Online", "amount": 1, "formId": null, "storeId": null, "currency": "USD", "expiryDate": null, "description": null, "embeddedCSS": null, "formResponse": null, "customCSSLink": null, "allowCustomPaymentAmounts": false}' + ); + + INSERT INTO "PaymentRequests" ( + "Id", "StoreDataId", "Status", "Blob", "Created", "Archived", "Blob2" + ) VALUES ( + 'expired-bug', + '7Nefq9u8DDYL56HAFskWdHNQ9ZCdfbkVWkH4xYhfzxiB', + 1, + NULL, + '2024-03-21 23:15:26.356677+00', + FALSE, + '{"email": "f.f@gmail.com", "title": "Online", "amount": 0.001, "formId": null, "storeId": null, "currency": "USD", "expiryDate": 1711066440, "description": null, "embeddedCSS": null, "formResponse": null, "customCSSLink": null, "allowCustomPaymentAmounts": false}' + ); + + """); + await tester.ContinueMigration(); + // The blob isn't cleaned yet, this is the migrator who does that + await conn.QuerySingleAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= '03463aab-844e-4d60-872f-26310b856131' + AND "Status" = 'Completed' + AND "Blob2"->>'currency' IS NOT NULL + """); + + await conn.QuerySingleAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= 'other' + AND "Status" = 'Processing' + """); + + await conn.QuerySingleAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= 'expired-bug' + AND "Status" = 'Processing' + """); + + var pr = ctx.PaymentRequests.First(r => r.Id == "03463aab-844e-4d60-872f-26310b856131"); + Assert.Equal("USD", pr.Currency); + Assert.Equal(0.001m, pr.Amount); + Assert.Equal(1711066440U, NBitcoin.Utils.DateTimeToUnixTime(pr.Expiry.Value)); +#pragma warning disable CS0618 // Type or member is obsolete + Assert.Null(pr.Blob); +#pragma warning restore CS0618 // Type or member is obsolete + + using var serverTester = CreateServerTester(); + serverTester.PayTester.Postgres = serverTester.PayTester.Postgres.Replace("btcpayserver", tester.dbname); + await serverTester.StartAsync(); + var dbContext = serverTester.PayTester.GetService().CreateContext(); + var migrator = serverTester.PayTester.GetService(); + await TestUtils.EventuallyAsync(async () => + { + Assert.True(await migrator.IsComplete()); + }); + var actualBlob2 = (string)(await conn.QuerySingleAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= '03463aab-844e-4d60-872f-26310b856131' + AND "Expiry" IS NOT NULL AND "Currency" IS NOT NULL AND "Amount"=0.001 + """)).Blob2; + var expectedBlob2 = new JObject() + { + ["email"] = "f.f@gmail.com", + ["title"] = "Online" + }; + Assert.Equal(JObject.Parse(actualBlob2), expectedBlob2); + + actualBlob2 = (string)(await conn.QuerySingleAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= 'other' + AND "Expiry" IS NULL AND "Amount"=1 + """)).Blob2; + expectedBlob2 = new JObject() + { + ["email"] = "f.f@gmail.com", + ["title"] = "Online" + }; + Assert.Equal(JObject.Parse(actualBlob2), expectedBlob2); + + // 'expired-bug' represents a PaymentRequest whose status isn't Expired even if its expiry is well past. + // Normally, on startup, the streamer should set the status to Expired + await TestUtils.EventuallyAsync(async () => + { + var r = await conn.QuerySingleOrDefaultAsync(""" + SELECT * FROM "PaymentRequests" + WHERE "Id"= 'expired-bug' + AND "Status" = 'Expired' + """); + Assert.NotNull(r); + }); + } + [Fact(Timeout = LongRunningTestTimeout)] [Trait("Integration", "Integration")] public async Task CanDoInvoiceMigrations() @@ -2889,6 +3010,7 @@ namespace BTCPayServer.Tests var handlers = tester.PayTester.GetService(); await acc.ImportOldInvoices(); + var dbContext = tester.PayTester.GetService().CreateContext(); var invoiceMigrator = tester.PayTester.GetService(); invoiceMigrator.BatchSize = 2; @@ -3164,12 +3286,14 @@ namespace BTCPayServer.Tests // Quick unrelated test on GetMonitoredInvoices var invoiceRepo = tester.PayTester.GetService(); var monitored = Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN")), i => i.Id == invoiceId); +#pragma warning disable CS0618 // Type or member is obsolete Assert.Single(monitored.Payments); - monitored = Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), true), i => i.Id == invoiceId); + monitored = Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), true), i => i.Id == invoiceId); Assert.Single(monitored.Payments); - // +#pragma warning restore CS0618 // Type or member is obsolete + // - app = await client.CreatePointOfSaleApp(acc.StoreId, new PointOfSaleAppRequest + app = await client.CreatePointOfSaleApp(acc.StoreId, new PointOfSaleAppRequest { AppName = "Cart", DefaultView = PosViewType.Cart, diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs index 2f9fe9e8c..102493cae 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs @@ -17,249 +17,249 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using TwentyTwenty.Storage; +using static System.Runtime.InteropServices.JavaScript.JSType; +using static QRCoder.PayloadGenerator; using PaymentRequestData = BTCPayServer.Data.PaymentRequestData; namespace BTCPayServer.Controllers.Greenfield { - [ApiController] - [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [EnableCors(CorsPolicies.All)] - public class GreenfieldPaymentRequestsController : ControllerBase - { - private readonly InvoiceRepository _InvoiceRepository; - private readonly UIInvoiceController _invoiceController; - private readonly PaymentRequestRepository _paymentRequestRepository; - private readonly CurrencyNameTable _currencyNameTable; - private readonly UserManager _userManager; - private readonly LinkGenerator _linkGenerator; + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [EnableCors(CorsPolicies.All)] + public class GreenfieldPaymentRequestsController : ControllerBase + { + private readonly InvoiceRepository _InvoiceRepository; + private readonly UIInvoiceController _invoiceController; + private readonly PaymentRequestRepository _paymentRequestRepository; + private readonly CurrencyNameTable _currencyNameTable; + private readonly UserManager _userManager; + private readonly LinkGenerator _linkGenerator; - public GreenfieldPaymentRequestsController( - InvoiceRepository invoiceRepository, - UIInvoiceController invoiceController, - PaymentRequestRepository paymentRequestRepository, - PaymentRequestService paymentRequestService, - CurrencyNameTable currencyNameTable, - UserManager userManager, - LinkGenerator linkGenerator) - { - _InvoiceRepository = invoiceRepository; - _invoiceController = invoiceController; - _paymentRequestRepository = paymentRequestRepository; - PaymentRequestService = paymentRequestService; - _currencyNameTable = currencyNameTable; - _userManager = userManager; - _linkGenerator = linkGenerator; - } + public GreenfieldPaymentRequestsController( + InvoiceRepository invoiceRepository, + UIInvoiceController invoiceController, + PaymentRequestRepository paymentRequestRepository, + PaymentRequestService paymentRequestService, + CurrencyNameTable currencyNameTable, + UserManager userManager, + LinkGenerator linkGenerator) + { + _InvoiceRepository = invoiceRepository; + _invoiceController = invoiceController; + _paymentRequestRepository = paymentRequestRepository; + PaymentRequestService = paymentRequestService; + _currencyNameTable = currencyNameTable; + _userManager = userManager; + _linkGenerator = linkGenerator; + } - [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [HttpGet("~/api/v1/stores/{storeId}/payment-requests")] - public async Task>> GetPaymentRequests(string storeId, bool includeArchived = false) - { - var prs = await _paymentRequestRepository.FindPaymentRequests( - new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = includeArchived }); - return Ok(prs.Select(FromModel)); - } + [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-requests")] + public async Task>> GetPaymentRequests(string storeId, bool includeArchived = false) + { + var prs = await _paymentRequestRepository.FindPaymentRequests( + new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = includeArchived }); + return Ok(prs.Select(FromModel)); + } - [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [HttpGet("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] - public async Task GetPaymentRequest(string storeId, string paymentRequestId) - { - var pr = await _paymentRequestRepository.FindPaymentRequests( - new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } }); + [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] + public async Task GetPaymentRequest(string storeId, string paymentRequestId) + { + var pr = await _paymentRequestRepository.FindPaymentRequests( + new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } }); - if (pr.Length == 0) + if (pr.Length == 0) + { + return PaymentRequestNotFound(); + } + + return Ok(FromModel(pr.First())); + } + + [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}/pay")] + public async Task PayPaymentRequest(string storeId, string paymentRequestId, [FromBody] PayPaymentRequestRequest pay, CancellationToken cancellationToken) + { + var pr = await this.PaymentRequestService.GetPaymentRequest(paymentRequestId); + if (pr is null || pr.StoreId != storeId) + return PaymentRequestNotFound(); + + var amount = pay?.Amount; + if (amount.HasValue && amount.Value <= 0) + { + ModelState.AddModelError(nameof(pay.Amount), "The amount should be more than 0"); + } + if (amount.HasValue && !pr.AllowCustomPaymentAmounts && amount.Value != pr.AmountDue) + { + ModelState.AddModelError(nameof(pay.Amount), "This payment request doesn't allow custom payment amount"); + } + + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + if (pr.Archived) + { + return this.CreateAPIError("archived", "You cannot pay an archived payment request"); + } + + if (pr.AmountDue <= 0) + { + return this.CreateAPIError("already-paid", "This payment request is already paid"); + } + + if (pr.ExpiryDate.HasValue && DateTime.UtcNow >= pr.ExpiryDate) + { + return this.CreateAPIError("expired", "This payment request is expired"); + } + + if (pay?.AllowPendingInvoiceReuse is true) + { + if (pr.Invoices.GetReusableInvoice(amount)?.Id is string invoiceId) + { + var inv = await _InvoiceRepository.GetInvoice(invoiceId); + return Ok(GreenfieldInvoiceController.ToModel(inv, _linkGenerator, Request)); + } + } + + try + { + var prData = await _paymentRequestRepository.FindPaymentRequest(pr.Id, null); + var invoice = await _invoiceController.CreatePaymentRequestInvoice(prData, amount, pr.AmountDue, this.StoreData, Request, cancellationToken); + return Ok(GreenfieldInvoiceController.ToModel(invoice, _linkGenerator, Request)); + } + catch (BitpayHttpException e) + { + return this.CreateAPIError(null, e.Message); + } + } + + [Authorize(Policy = Policies.CanModifyPaymentRequests, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] + public async Task ArchivePaymentRequest(string storeId, string paymentRequestId) + { + var pr = await _paymentRequestRepository.FindPaymentRequests( + new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId }, IncludeArchived = false }); + if (pr.Length == 0) + { + return PaymentRequestNotFound(); + } + + await _paymentRequestRepository.ArchivePaymentRequest(pr.First().Id); + return Ok(); + } + + [HttpPost("~/api/v1/stores/{storeId}/payment-requests")] + [HttpPut("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] + [Authorize(Policy = Policies.CanModifyPaymentRequests, + AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task CreateOrUpdatePaymentRequest( + [FromRoute] string storeId, + PaymentRequestBaseData request, + [FromRoute] string paymentRequestId = null) + { + if (request is null) + return BadRequest(); + + if (request.Amount <= 0) { - return PaymentRequestNotFound(); + ModelState.AddModelError(nameof(request.Amount), "Please provide an amount greater than 0"); } + if (!string.IsNullOrEmpty(request.Currency) && + _currencyNameTable.GetCurrencyData(request.Currency, false) == null) + ModelState.AddModelError(nameof(request.Currency), "Invalid currency"); + if (string.IsNullOrEmpty(request.Currency)) + request.Currency = null; + if (string.IsNullOrEmpty(request.Title)) + ModelState.AddModelError(nameof(request.Title), "Title is required"); - return Ok(FromModel(pr.First())); - } + PaymentRequestData pr; + if (paymentRequestId is not null) + { + pr = (await _paymentRequestRepository.FindPaymentRequests( + new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } })).FirstOrDefault(); + if (pr is null) + return PaymentRequestNotFound(); + if ((pr.Amount != request.Amount && request.Amount != 0.0m) || + (pr.Currency != request.Currency && request.Currency != null)) + { + var prWithInvoices = await this.PaymentRequestService.GetPaymentRequest(paymentRequestId, GetUserId()); + if (prWithInvoices.Invoices.Any()) + { + ModelState.AddModelError(nameof(request.Amount), "Amount and currency are not editable once payment request has invoices"); + } + else + { + if (request.Amount != 0.0m) + pr.Amount = request.Amount; + if (request.Currency != null) + pr.Currency = request.Currency; + } + } + pr.Expiry = request.ExpiryDate; + } + else + { + pr = new PaymentRequestData() + { + StoreDataId = storeId, + Status = Client.Models.PaymentRequestStatus.Pending, + Created = DateTimeOffset.UtcNow, + Amount = request.Amount, + Currency = request.Currency ?? StoreData.GetStoreBlob().DefaultCurrency, + Expiry = request.ExpiryDate, + }; + } - [Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [HttpPost("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}/pay")] - public async Task PayPaymentRequest(string storeId, string paymentRequestId, [FromBody] PayPaymentRequestRequest pay, CancellationToken cancellationToken) - { - var pr = await this.PaymentRequestService.GetPaymentRequest(paymentRequestId); - if (pr is null || pr.StoreId != storeId) - return PaymentRequestNotFound(); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); - var amount = pay?.Amount; - if (amount.HasValue && amount.Value <= 0) - { - ModelState.AddModelError(nameof(pay.Amount), "The amount should be more than 0"); - } - if (amount.HasValue && !pr.AllowCustomPaymentAmounts && amount.Value != pr.AmountDue) - { - ModelState.AddModelError(nameof(pay.Amount), "This payment request doesn't allow custom payment amount"); - } + var blob = pr.GetBlob(); + pr.SetBlob(new() + { + AllowCustomPaymentAmounts = request.AllowCustomPaymentAmounts, + Description = request.Description, + Email = request.Email, + FormId = request.FormId, + Title = request.Title, + FormResponse = blob.FormId != request.FormId ? null : blob.FormResponse + }); + pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); + return Ok(FromModel(pr)); + } + public Data.StoreData StoreData => HttpContext.GetStoreData(); - if (!ModelState.IsValid) - return this.CreateValidationError(ModelState); + public PaymentRequestService PaymentRequestService { get; } - if (pr.Archived) - { - return this.CreateAPIError("archived", "You cannot pay an archived payment request"); - } + private string GetUserId() => _userManager.GetUserId(User); - if (pr.AmountDue <= 0) - { - return this.CreateAPIError("already-paid", "This payment request is already paid"); - } + private static Client.Models.PaymentRequestBaseData FromModel(PaymentRequestData data) + { + var blob = data.GetBlob(); + return new Client.Models.PaymentRequestBaseData() + { + CreatedTime = data.Created, + Id = data.Id, + StoreId = data.StoreDataId, + Status = data.Status, + Archived = data.Archived, + Amount = data.Amount, + Currency = data.Currency, + Description = blob.Description, + Title = blob.Title, + ExpiryDate = data.Expiry, + Email = blob.Email, + AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts, + FormResponse = blob.FormResponse, + FormId = blob.FormId + }; + } - if (pr.ExpiryDate.HasValue && DateTime.UtcNow >= pr.ExpiryDate) - { - return this.CreateAPIError("expired", "This payment request is expired"); - } - - if (pay?.AllowPendingInvoiceReuse is true) - { - if (pr.Invoices.GetReusableInvoice(amount)?.Id is string invoiceId) - { - var inv = await _InvoiceRepository.GetInvoice(invoiceId); - return Ok(GreenfieldInvoiceController.ToModel(inv, _linkGenerator, Request)); - } - } - - try - { - var prData = await _paymentRequestRepository.FindPaymentRequest(pr.Id, null); - var invoice = await _invoiceController.CreatePaymentRequestInvoice(prData, amount, pr.AmountDue, this.StoreData, Request, cancellationToken); - return Ok(GreenfieldInvoiceController.ToModel(invoice, _linkGenerator, Request)); - } - catch (BitpayHttpException e) - { - return this.CreateAPIError(null, e.Message); - } - } - - [Authorize(Policy = Policies.CanModifyPaymentRequests, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [HttpDelete("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] - public async Task ArchivePaymentRequest(string storeId, string paymentRequestId) - { - var pr = await _paymentRequestRepository.FindPaymentRequests( - new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId }, IncludeArchived = false }); - if (pr.Length == 0) - { - return PaymentRequestNotFound(); - } - - await _paymentRequestRepository.ArchivePaymentRequest(pr.First().Id); - return Ok(); - } - - [HttpPost("~/api/v1/stores/{storeId}/payment-requests")] - [Authorize(Policy = Policies.CanModifyPaymentRequests, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task CreatePaymentRequest(string storeId, - CreatePaymentRequestRequest request) - { - var validationResult = await Validate(null, request); - if (validationResult != null) - { - return validationResult; - } - request.Currency ??= StoreData.GetStoreBlob().DefaultCurrency; - var pr = new PaymentRequestData() - { - StoreDataId = storeId, - Status = Client.Models.PaymentRequestData.PaymentRequestStatus.Pending, - Created = DateTimeOffset.UtcNow - }; - request.FormResponse = null; - request.StoreId = storeId; - pr.SetBlob(request); - pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); - return Ok(FromModel(pr)); - } - public Data.StoreData StoreData => HttpContext.GetStoreData(); - - public PaymentRequestService PaymentRequestService { get; } - - [HttpPut("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")] - [Authorize(Policy = Policies.CanModifyPaymentRequests, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task UpdatePaymentRequest(string storeId, - string paymentRequestId, [FromBody] UpdatePaymentRequestRequest request) - { - var validationResult = await Validate(paymentRequestId, request); - if (validationResult != null) - { - return validationResult; - } - request.Currency ??= StoreData.GetStoreBlob().DefaultCurrency; - var pr = await _paymentRequestRepository.FindPaymentRequests( - new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } }); - if (pr.Length == 0) - { - return PaymentRequestNotFound(); - } - - var updatedPr = pr.First(); - var blob = updatedPr.GetBlob(); - request.FormResponse = blob.FormResponse; - request.StoreId = storeId; - updatedPr.SetBlob(request); - - return Ok(FromModel(await _paymentRequestRepository.CreateOrUpdatePaymentRequest(updatedPr))); - } - private string GetUserId() => _userManager.GetUserId(User); - - private async Task Validate(string id, PaymentRequestBaseData data) - { - if (data is null) - return BadRequest(); - - if (id != null) - { - var pr = await this.PaymentRequestService.GetPaymentRequest(id, GetUserId()); - if (pr.Amount != data.Amount) - { - if (pr.Invoices.Any()) - ModelState.AddModelError(nameof(data.Amount), "Amount and currency are not editable once payment request has invoices"); - } - } - if (data.Amount <= 0) - { - ModelState.AddModelError(nameof(data.Amount), "Please provide an amount greater than 0"); - } - - if (!string.IsNullOrEmpty(data.Currency) && - _currencyNameTable.GetCurrencyData(data.Currency, false) == null) - ModelState.AddModelError(nameof(data.Currency), "Invalid currency"); - if (string.IsNullOrEmpty(data.Currency)) - data.Currency = null; - if (string.IsNullOrEmpty(data.Title)) - ModelState.AddModelError(nameof(data.Title), "Title is required"); - - return !ModelState.IsValid ? this.CreateValidationError(ModelState) : null; - } - - private static Client.Models.PaymentRequestData FromModel(PaymentRequestData data) - { - var blob = data.GetBlob(); - return new Client.Models.PaymentRequestData() - { - CreatedTime = data.Created, - Id = data.Id, - StoreId = data.StoreDataId, - Status = data.Status, - Archived = data.Archived, - Amount = blob.Amount, - Currency = blob.Currency, - Description = blob.Description, - Title = blob.Title, - ExpiryDate = blob.ExpiryDate, - Email = blob.Email, - AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts, - FormResponse = blob.FormResponse, - FormId = blob.FormId - }; - } - - private IActionResult PaymentRequestNotFound() - { - return this.CreateAPIError(404, "payment-request-not-found", "The payment request was not found"); - } - } + private IActionResult PaymentRequestNotFound() + { + return this.CreateAPIError(404, "payment-request-not-found", "The payment request was not found"); + } + } } diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 56cdcf2b0..6ca2baf6e 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -28,7 +28,6 @@ using InvoiceData = BTCPayServer.Client.Models.InvoiceData; using Language = BTCPayServer.Client.Models.Language; using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData; using NotificationData = BTCPayServer.Client.Models.NotificationData; -using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData; using PayoutData = BTCPayServer.Client.Models.PayoutData; using PayoutProcessorData = BTCPayServer.Client.Models.PayoutProcessorData; using PullPaymentData = BTCPayServer.Client.Models.PullPaymentData; @@ -596,16 +595,16 @@ namespace BTCPayServer.Controllers.Greenfield return Task.FromResult(GetFromActionResult(GetController().GetHealth())); } - public override async Task> GetPaymentRequests(string storeId, + public override async Task> GetPaymentRequests(string storeId, bool includeArchived = false, CancellationToken token = default) { return GetFromActionResult(await GetController().GetPaymentRequests(storeId, includeArchived)); } - public override async Task GetPaymentRequest(string storeId, string paymentRequestId, + public override async Task GetPaymentRequest(string storeId, string paymentRequestId, CancellationToken token = default) { - return GetFromActionResult( + return GetFromActionResult( await GetController().GetPaymentRequest(storeId, paymentRequestId)); } @@ -621,19 +620,19 @@ namespace BTCPayServer.Controllers.Greenfield await GetController().PayPaymentRequest(storeId, paymentRequestId, request, token)); } - public override async Task CreatePaymentRequest(string storeId, - CreatePaymentRequestRequest request, CancellationToken token = default) + public override async Task CreatePaymentRequest(string storeId, + PaymentRequestBaseData request, CancellationToken token = default) { - return GetFromActionResult( - await GetController().CreatePaymentRequest(storeId, request)); + return GetFromActionResult( + await GetController().CreateOrUpdatePaymentRequest(storeId, request)); } - public override async Task UpdatePaymentRequest(string storeId, string paymentRequestId, - UpdatePaymentRequestRequest request, + public override async Task UpdatePaymentRequest(string storeId, string paymentRequestId, + PaymentRequestBaseData request, CancellationToken token = default) { - return GetFromActionResult( - await GetController().UpdatePaymentRequest(storeId, paymentRequestId, request)); + return GetFromActionResult( + await GetController().CreateOrUpdatePaymentRequest(storeId, request, paymentRequestId)); } public override async Task GetCurrentAPIKeyInfo(CancellationToken token = default) diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index faf94f540..30dd5d40f 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -158,7 +158,7 @@ namespace BTCPayServer.Controllers new CreateInvoiceRequest { Metadata = invoiceMetadata, - Currency = prBlob.Currency, + Currency = prData.Currency, Amount = amount, Checkout = { RedirectURL = redirectUrl }, Receipt = new InvoiceDataBase.ReceiptOptions { Enabled = false } diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index 9893cbe79..8071a5776 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers StoreId = store.Id, Skip = model.Skip, Count = model.Count, - Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse(s, true)).ToArray(), + Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse(s, true)).ToArray(), IncludeArchived = fs.GetFilterBool("includearchived") ?? false }); @@ -110,7 +110,7 @@ namespace BTCPayServer.Controllers var blob = data.GetBlob(); return new ViewPaymentRequestViewModel(data) { - AmountFormatted = _displayFormatter.Currency(blob.Amount, blob.Currency) + AmountFormatted = _displayFormatter.Currency(data.Amount, data.Currency) }; }).ToList(); @@ -180,7 +180,7 @@ namespace BTCPayServer.Controllers data.Archived = viewModel.Archived; var blob = data.GetBlob(); - if (blob.Amount != viewModel.Amount && payReqId != null) + if (data.Amount != viewModel.Amount && payReqId != null) { var prInvoices = (await _PaymentRequestService.GetPaymentRequest(payReqId, GetUserId())).Invoices; if (prInvoices.Any()) @@ -198,9 +198,9 @@ namespace BTCPayServer.Controllers blob.Title = viewModel.Title; blob.Email = viewModel.Email; blob.Description = viewModel.Description; - blob.Amount = viewModel.Amount; - blob.ExpiryDate = viewModel.ExpiryDate?.ToUniversalTime(); - blob.Currency = viewModel.Currency ?? store.GetStoreBlob().DefaultCurrency; + data.Amount = viewModel.Amount; + data.Expiry = viewModel.ExpiryDate?.ToUniversalTime(); + data.Currency = viewModel.Currency ?? store.GetStoreBlob().DefaultCurrency; blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts; blob.FormId = viewModel.FormId; @@ -212,7 +212,6 @@ namespace BTCPayServer.Controllers } data = await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(data); - _EventAggregator.Publish(new PaymentRequestUpdated { Data = data, PaymentRequestId = data.Id, }); TempData[WellKnownTempData.SuccessMessage] = isNewPaymentRequest ? StringLocalizer["Payment request \"{0}\" created successfully", viewModel.Title].Value diff --git a/BTCPayServer/Data/PaymentRequestBlob.cs b/BTCPayServer/Data/PaymentRequestBlob.cs new file mode 100644 index 000000000..d8d09d873 --- /dev/null +++ b/BTCPayServer/Data/PaymentRequestBlob.cs @@ -0,0 +1,19 @@ +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace BTCPayServer.Data +{ + public class PaymentRequestBlob + { + public string Title { get; set; } + public string Description { get; set; } + public string Email { get; set; } + public bool AllowCustomPaymentAmounts { get; set; } + + public string FormId { get; set; } + + public JObject FormResponse { get; set; } + } +} diff --git a/BTCPayServer/Data/PaymentRequestDataExtensions.cs b/BTCPayServer/Data/PaymentRequestDataExtensions.cs index e5ceb7cb4..65c7c1f5c 100644 --- a/BTCPayServer/Data/PaymentRequestDataExtensions.cs +++ b/BTCPayServer/Data/PaymentRequestDataExtensions.cs @@ -1,20 +1,27 @@ using System; using BTCPayServer.Client.Models; using NBXplorer; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace BTCPayServer.Data { public static class PaymentRequestDataExtensions { - public static PaymentRequestBaseData GetBlob(this PaymentRequestData paymentRequestData) + public static readonly JsonSerializerSettings DefaultSerializerSettings; + public static readonly JsonSerializer DefaultSerializer; + static PaymentRequestDataExtensions() { - return paymentRequestData.HasTypedBlob().GetBlob() ?? new PaymentRequestBaseData(); + (DefaultSerializerSettings, DefaultSerializer) = BlobSerializer.CreateSerializer(null as NBitcoin.Network); + } + public static PaymentRequestBlob GetBlob(this PaymentRequestData paymentRequestData) + { + return paymentRequestData.HasTypedBlob().GetBlob(DefaultSerializerSettings) ?? new PaymentRequestBlob(); } - public static void SetBlob(this PaymentRequestData paymentRequestData, PaymentRequestBaseData blob) + public static void SetBlob(this PaymentRequestData paymentRequestData, PaymentRequestBlob blob) { - paymentRequestData.HasTypedBlob().SetBlob(blob); + paymentRequestData.HasTypedBlob().SetBlob(blob, DefaultSerializer); } } } diff --git a/BTCPayServer/HostedServices/PaymentRequestsMigratorHostedService.cs b/BTCPayServer/HostedServices/PaymentRequestsMigratorHostedService.cs index 1d7d6a771..51bfb21e1 100644 --- a/BTCPayServer/HostedServices/PaymentRequestsMigratorHostedService.cs +++ b/BTCPayServer/HostedServices/PaymentRequestsMigratorHostedService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Data; using BTCPayServer.Migrations; +using BTCPayServer.PaymentRequest; using BTCPayServer.Services.Invoices; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -15,20 +16,24 @@ namespace BTCPayServer.HostedServices { public class PaymentRequestsMigratorHostedService : BlobMigratorHostedService { + private readonly PaymentRequestStreamer _paymentRequestStreamer; + public PaymentRequestsMigratorHostedService( ILogger logs, ISettingsRepository settingsRepository, + PaymentRequestStreamer paymentRequestStreamer, ApplicationDbContextFactory applicationDbContextFactory) : base(logs, settingsRepository, applicationDbContextFactory) { + _paymentRequestStreamer = paymentRequestStreamer; } - public override string SettingsKey => "PaymentRequestsMigration"; + public override string SettingsKey => "PaymentRequestsMigration2"; protected override IQueryable GetQuery(ApplicationDbContext ctx, DateTimeOffset? progress) { #pragma warning disable CS0618 // Type or member is obsolete var query = progress is DateTimeOffset last2 ? - ctx.PaymentRequests.Where(i => i.Created < last2 && !(i.Blob == null && i.Blob2 != null)) : - ctx.PaymentRequests.Where(i => !(i.Blob == null && i.Blob2 != null)); + ctx.PaymentRequests.Where(i => i.Created < last2 && !((i.Blob == null || i.Blob.Length == 0) && i.Blob2 != null && i.Currency != null)) : + ctx.PaymentRequests.Where(i => !((i.Blob == null || i.Blob.Length == 0) && i.Blob2 != null && i.Currency != null)); return query.OrderByDescending(i => i.Created); #pragma warning restore CS0618 // Type or member is obsolete } @@ -38,6 +43,7 @@ namespace BTCPayServer.HostedServices Logs.LogInformation("Post-migration VACUUM (FULL, ANALYZE)"); await ctx.Database.ExecuteSqlRawAsync("VACUUM (FULL, ANALYZE) \"PaymentRequests\"", cancellationToken); Logs.LogInformation("Post-migration VACUUM (FULL, ANALYZE) finished"); + _paymentRequestStreamer.CheckExpirable(); } protected override DateTimeOffset ProcessEntities(ApplicationDbContext ctx, List entities) @@ -46,6 +52,8 @@ namespace BTCPayServer.HostedServices // But Modified isn't set as it happens before the ctx is bound to the entity. foreach (var entity in entities) { + // Make sure the blob is clean + entity.SetBlob(entity.GetBlob()); ctx.PaymentRequests.Entry(entity).State = EntityState.Modified; } return entities[^1].Created; diff --git a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs index d8d740ce9..d62fe5010 100644 --- a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs +++ b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Globalization; using System.Threading.Tasks; using BTCPayServer.Client.Models; @@ -30,19 +30,20 @@ public class PaymentRequestWebhookDeliveryRequest : WebhookSender.WebhookDeliver req.Email += $",{bmb}"; } - req.Subject = Interpolate(req.Subject, blob); - req.Body = Interpolate(req.Body, blob); + req.Subject = Interpolate(req.Subject, _evt.Data); + req.Body = Interpolate(req.Body, _evt.Data); return Task.FromResult(req)!; } - private string Interpolate(string str, PaymentRequestBaseData blob) + private string Interpolate(string str, Data.PaymentRequestData data) { + var blob = data.GetBlob(); var res = str.Replace("{PaymentRequest.Id}", _evt.Data.Id) - .Replace("{PaymentRequest.Price}", blob.Amount.ToString(CultureInfo.InvariantCulture)) - .Replace("{PaymentRequest.Currency}", blob.Currency) + .Replace("{PaymentRequest.Price}", data.Amount.ToString(CultureInfo.InvariantCulture)) + .Replace("{PaymentRequest.Currency}", data.Currency) .Replace("{PaymentRequest.Title}", blob.Title) .Replace("{PaymentRequest.Description}", blob.Description) - .Replace("{PaymentRequest.Status}", _evt.Data.Status.ToString()); + .Replace("{PaymentRequest.Status}", data.Status.ToString()); res = InterpolateJsonField(res, "PaymentRequest.FormResponse", blob.FormResponse); return res; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 3614df244..8fd674da0 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -92,6 +92,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => o.GetRequiredService().Create("","")); + services.TryAddSingleton(); services.AddSingleton(o => o.GetRequiredService>().Value); services.AddSingleton(o => o.GetRequiredService>().Value.SerializerSettings); @@ -431,7 +432,8 @@ o.GetRequiredService>().ToDictionary(o => o.P services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs index c9b4bc872..c1c000c44 100644 --- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -43,10 +43,10 @@ namespace BTCPayServer.Models.PaymentRequestViewModels var blob = data.GetBlob(); FormId = blob.FormId; Title = blob.Title; - Amount = blob.Amount; - Currency = blob.Currency; + Amount = data.Amount; + Currency = data.Currency; Description = blob.Description; - ExpiryDate = blob.ExpiryDate?.UtcDateTime; + ExpiryDate = data.Expiry?.UtcDateTime; Email = blob.Email; AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts; FormResponse = blob.FormResponse is null @@ -101,25 +101,25 @@ namespace BTCPayServer.Models.PaymentRequestViewModels var blob = data.GetBlob(); Archived = data.Archived; Title = blob.Title; - Amount = blob.Amount; - Currency = blob.Currency; + Amount = data.Amount; + Currency = data.Currency; Description = blob.Description; - ExpiryDate = blob.ExpiryDate?.UtcDateTime; + ExpiryDate = data.Expiry?.UtcDateTime; Email = blob.Email; AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts; switch (data.Status) { - case Client.Models.PaymentRequestData.PaymentRequestStatus.Pending: + case Client.Models.PaymentRequestStatus.Pending: Status = "Pending"; IsPending = true; break; - case Client.Models.PaymentRequestData.PaymentRequestStatus.Processing: + case Client.Models.PaymentRequestStatus.Processing: Status = "Processing"; break; - case Client.Models.PaymentRequestData.PaymentRequestStatus.Completed: + case Client.Models.PaymentRequestStatus.Completed: Status = "Settled"; break; - case Client.Models.PaymentRequestData.PaymentRequestStatus.Expired: + case Client.Models.PaymentRequestStatus.Expired: Status = "Expired"; break; default: diff --git a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs index 2034921c7..70d6eef79 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Events; @@ -10,13 +11,13 @@ using BTCPayServer.HostedServices; using BTCPayServer.Logging; using BTCPayServer.Services; using BTCPayServer.Services.PaymentRequests; +using Google.Apis.Storage.v1.Data; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData; namespace BTCPayServer.PaymentRequest { @@ -108,61 +109,52 @@ namespace BTCPayServer.PaymentRequest private readonly PaymentRequestRepository _PaymentRequestRepository; private readonly PrettyNameProvider _prettyNameProvider; private readonly PaymentRequestService _PaymentRequestService; - + private readonly DelayedTaskScheduler _delayedTaskScheduler; public PaymentRequestStreamer(EventAggregator eventAggregator, IHubContext hubContext, PaymentRequestRepository paymentRequestRepository, PrettyNameProvider prettyNameProvider, PaymentRequestService paymentRequestService, + DelayedTaskScheduler delayedTaskScheduler, Logs logs) : base(eventAggregator, logs) { _HubContext = hubContext; _PaymentRequestRepository = paymentRequestRepository; _prettyNameProvider = prettyNameProvider; _PaymentRequestService = paymentRequestService; + _delayedTaskScheduler = delayedTaskScheduler; } public override async Task StartAsync(CancellationToken cancellationToken) { + this.PushEvent(new Starting()); await base.StartAsync(cancellationToken); - _CheckingPendingPayments = CheckingPendingPayments(cancellationToken) - .ContinueWith(_ => _CheckingPendingPayments = null, TaskScheduler.Default); } - private async Task CheckingPendingPayments(CancellationToken cancellationToken) + internal void CheckExpirable() { - Logs.PayServer.LogInformation("Starting payment request expiration watcher"); - var items = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery - { - Status = new[] - { - PaymentRequestData.PaymentRequestStatus.Pending, - PaymentRequestData.PaymentRequestStatus.Processing - } - }, cancellationToken); - Logs.PayServer.LogInformation($"{items.Length} pending payment requests being checked since last run"); - await Task.WhenAll(items.Select(i => _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(i)) - .ToArray()); + this.PushEvent(new Starting()); } - Task _CheckingPendingPayments; - - public override async Task StopAsync(CancellationToken cancellationToken) - { - await base.StopAsync(cancellationToken); - await (_CheckingPendingPayments ?? Task.CompletedTask); - } + record Starting; protected override void SubscribeToEvents() { Subscribe(); - Subscribe(); + Subscribe(); } protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) { - if (evt is InvoiceEvent invoiceEvent) + if (evt is Starting) + { + foreach (var req in await _PaymentRequestRepository.GetExpirablePaymentRequests(cancellationToken)) + { + UpdateOnExpire(req); + } + } + else if (evt is InvoiceEvent invoiceEvent) { foreach (var paymentId in PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice)) { @@ -194,34 +186,28 @@ namespace BTCPayServer.PaymentRequest await InfoUpdated(paymentId); } } - else if (evt is PaymentRequestUpdated updated) + else if (evt is PaymentRequestEvent updated) { - await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(updated.PaymentRequestId); - await InfoUpdated(updated.PaymentRequestId); + await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(updated.Data.Id); + await InfoUpdated(updated.Data.Id); - var isPending = updated.Data.Status is - PaymentRequestData.PaymentRequestStatus.Pending or - PaymentRequestData.PaymentRequestStatus.Processing; - var expiry = updated.Data.GetBlob().ExpiryDate; - if (isPending && expiry.HasValue) - { - QueueExpiryTask( - updated.PaymentRequestId, - expiry.Value.UtcDateTime, - cancellationToken); - } + UpdateOnExpire(updated.Data); } } - private void QueueExpiryTask(string paymentRequestId, DateTime expiry, CancellationToken cancellationToken) + private void UpdateOnExpire(Data.PaymentRequestData data) { - Task.Run(async () => + if (data is + { + Status: PaymentRequestStatus.Pending or PaymentRequestStatus.Processing, + Expiry: { } e + }) { - var delay = expiry - DateTime.UtcNow; - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken); - await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentRequestId); - }, cancellationToken); + _delayedTaskScheduler.Schedule($"PAYREQ_{data.Id}", e, async () => + { + await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(data.Id); + }); + } } private async Task InfoUpdated(string paymentRequestId) diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index 4b6d18377..008dd4737 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -52,28 +52,28 @@ namespace BTCPayServer.PaymentRequest { var blob = pr.GetBlob(); var currentStatus = pr.Status; - if (blob.ExpiryDate.HasValue) + if (pr.Expiry.HasValue) { - if (blob.ExpiryDate.Value <= DateTimeOffset.UtcNow) - currentStatus = Client.Models.PaymentRequestData.PaymentRequestStatus.Expired; + if (pr.Expiry.Value <= DateTimeOffset.UtcNow) + currentStatus = Client.Models.PaymentRequestStatus.Expired; } - else if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Completed) + else if (currentStatus != Client.Models.PaymentRequestStatus.Completed) { - currentStatus = Client.Models.PaymentRequestData.PaymentRequestStatus.Pending; + currentStatus = Client.Models.PaymentRequestStatus.Pending; } - if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Expired) + if (currentStatus != Client.Models.PaymentRequestStatus.Expired) { var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id); - var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); + var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(pr.Currency, invoices, true); currentStatus = - (PaidEnough: contributions.Total >= blob.Amount, - SettledEnough: contributions.TotalSettled >= blob.Amount) switch + (PaidEnough: contributions.Total >= pr.Amount, + SettledEnough: contributions.TotalSettled >= pr.Amount) switch { - { SettledEnough: true } => Client.Models.PaymentRequestData.PaymentRequestStatus.Completed, - { PaidEnough: true } => Client.Models.PaymentRequestData.PaymentRequestStatus.Processing, - _ => Client.Models.PaymentRequestData.PaymentRequestStatus.Pending + { SettledEnough: true } => Client.Models.PaymentRequestStatus.Completed, + { PaidEnough: true } => Client.Models.PaymentRequestStatus.Processing, + _ => Client.Models.PaymentRequestStatus.Pending }; } @@ -94,20 +94,20 @@ namespace BTCPayServer.PaymentRequest var blob = pr.GetBlob(); var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(id); - var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); - var amountDue = blob.Amount - paymentStats.Total; + var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(pr.Currency, invoices, true); + var amountDue = pr.Amount - paymentStats.Total; var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime) .FirstOrDefault(entity => entity.Status == InvoiceStatus.New); return new ViewPaymentRequestViewModel(pr) { Archived = pr.Archived, - AmountFormatted = _displayFormatter.Currency(blob.Amount, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol), + AmountFormatted = _displayFormatter.Currency(pr.Amount, pr.Currency, DisplayFormatter.CurrencyFormat.Symbol), AmountCollected = paymentStats.Total, - AmountCollectedFormatted = _displayFormatter.Currency(paymentStats.Total, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol), + AmountCollectedFormatted = _displayFormatter.Currency(paymentStats.Total, pr.Currency, DisplayFormatter.CurrencyFormat.Symbol), AmountDue = amountDue, - AmountDueFormatted = _displayFormatter.Currency(amountDue, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol), - CurrencyData = _currencies.GetCurrencyData(blob.Currency, true), + AmountDueFormatted = _displayFormatter.Currency(amountDue, pr.Currency, DisplayFormatter.CurrencyFormat.Symbol), + CurrencyData = _currencies.GetCurrencyData(pr.Currency, true), LastUpdated = DateTime.UtcNow, FormId = blob.FormId, FormSubmitted = blob.FormResponse is not null, @@ -126,7 +126,7 @@ namespace BTCPayServer.PaymentRequest { Id = entity.Id, Amount = entity.Price, - AmountFormatted = _displayFormatter.Currency(entity.Price, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol), + AmountFormatted = _displayFormatter.Currency(entity.Price, pr.Currency, DisplayFormatter.CurrencyFormat.Symbol), Currency = entity.Currency, ExpiryDate = entity.ExpirationTime.DateTime, State = state, diff --git a/BTCPayServer/Services/DelayedTaskScheduler.cs b/BTCPayServer/Services/DelayedTaskScheduler.cs new file mode 100644 index 000000000..8eb285da1 --- /dev/null +++ b/BTCPayServer/Services/DelayedTaskScheduler.cs @@ -0,0 +1,84 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using Amazon.Runtime.Internal.Util; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Services +{ + public class DelayedTaskScheduler : IDisposable + { + public DelayedTaskScheduler(ILogger logger) + { + Logger = logger; + } + record class TimerState(string Key, Func Act); + private readonly Dictionary _timers = new(); + bool disposed = false; + + public ILogger Logger { get; } + + public void Schedule(string key, DateTimeOffset executeAt, Func act) + { + lock (_timers) + { + if (disposed) + return; + _timers.TryGetValue(key, out var existing); + if (existing != null) + { + existing.Dispose(); + _timers.Remove(key); + } + var due = executeAt - DateTimeOffset.UtcNow; + if (due < TimeSpan.Zero) + due = TimeSpan.Zero; + var timer = new Timer(TimerCallback, new TimerState(key, act), Timeout.Infinite, Timeout.Infinite); + _timers.Add(key, timer); + timer.Change((long)due.TotalMilliseconds, (long)Timeout.Infinite); + } + } + + void TimerCallback(object? state) + { + var s = (TimerState)state!; + Task.Run(async () => + { + try + { + await s.Act(); + } + catch (Exception ex) + { + Logger.LogError(ex, $"Error executing delayed task for key {s.Key}"); + } + finally + { + Timer? timer = null; + lock (_timers) + { + if (_timers.TryGetValue(s.Key, out timer)) + { + _timers.Remove(s.Key); + } + } + timer?.Dispose(); + } + }); + } + + public void Dispose() + { + lock (_timers) + { + disposed = true; + foreach (var t in _timers.Values) + t.Dispose(); + _timers.Clear(); + } + } + } +} diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs index 9a67aa4c8..a20271c82 100644 --- a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs +++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs @@ -95,7 +95,7 @@ namespace BTCPayServer.Services.PaymentRequests return result; } - public async Task UpdatePaymentRequestStatus(string paymentRequestId, Client.Models.PaymentRequestData.PaymentRequestStatus status, CancellationToken cancellationToken = default) + public async Task UpdatePaymentRequestStatus(string paymentRequestId, Client.Models.PaymentRequestStatus status, CancellationToken cancellationToken = default) { await using var context = _ContextFactory.CreateContext(); var paymentRequestData = await context.FindAsync(paymentRequestId); @@ -113,7 +113,17 @@ namespace BTCPayServer.Services.PaymentRequests Type = PaymentRequestEvent.StatusChanged }); } - + public async Task GetExpirablePaymentRequests(CancellationToken cancellationToken = default) + { + using var context = _ContextFactory.CreateContext(); + var queryable = context.PaymentRequests.Include(data => data.StoreData).AsQueryable(); + queryable = + queryable + .Where(data => + (data.Status == Client.Models.PaymentRequestStatus.Pending || data.Status == Client.Models.PaymentRequestStatus.Processing) && + data.Expiry != null); + return await queryable.ToArrayAsync(cancellationToken); + } public async Task FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default) { using var context = _ContextFactory.CreateContext(); @@ -202,17 +212,11 @@ namespace BTCPayServer.Services.PaymentRequests } } - public class PaymentRequestUpdated - { - public string PaymentRequestId { get; set; } - public PaymentRequestData Data { get; set; } - } - public class PaymentRequestQuery { public string StoreId { get; set; } public bool IncludeArchived { get; set; } = true; - public Client.Models.PaymentRequestData.PaymentRequestStatus[] Status { get; set; } + public Client.Models.PaymentRequestStatus[] Status { get; set; } public string UserId { get; set; } public int? Skip { get; set; } public int? Count { get; set; }