Fix: PaymentRequests created via API never expires (#6657)

This commit is contained in:
Nicolas Dorier
2025-04-08 14:17:31 +09:00
committed by GitHub
parent c373f1e08f
commit ce83e4d96d
28 changed files with 730 additions and 429 deletions

View File

@@ -9,18 +9,18 @@ namespace BTCPayServer.Client;
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<PaymentRequestData>> GetPaymentRequests(string storeId,
public virtual async Task<IEnumerable<PaymentRequestBaseData>> GetPaymentRequests(string storeId,
bool includeArchived = false,
CancellationToken token = default)
{
return await SendHttpRequest<IEnumerable<PaymentRequestData>>($"api/v1/stores/{storeId}/payment-requests",
return await SendHttpRequest<IEnumerable<PaymentRequestBaseData>>($"api/v1/stores/{storeId}/payment-requests",
new Dictionary<string, object> { { nameof(includeArchived), includeArchived } }, HttpMethod.Get, token);
}
public virtual async Task<PaymentRequestData> GetPaymentRequest(string storeId, string paymentRequestId,
public virtual async Task<PaymentRequestBaseData> GetPaymentRequest(string storeId, string paymentRequestId,
CancellationToken token = default)
{
return await SendHttpRequest<PaymentRequestData>($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", null, HttpMethod.Get, token);
return await SendHttpRequest<PaymentRequestBaseData>($"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<InvoiceData>($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}/pay", request, HttpMethod.Post, token);
}
public virtual async Task<PaymentRequestData> CreatePaymentRequest(string storeId,
CreatePaymentRequestRequest request, CancellationToken token = default)
public virtual async Task<PaymentRequestBaseData> CreatePaymentRequest(string storeId,
PaymentRequestBaseData request, CancellationToken token = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
return await SendHttpRequest<PaymentRequestData>($"api/v1/stores/{storeId}/payment-requests", request, HttpMethod.Post, token);
return await SendHttpRequest<PaymentRequestBaseData>($"api/v1/stores/{storeId}/payment-requests", request, HttpMethod.Post, token);
}
public virtual async Task<PaymentRequestData> UpdatePaymentRequest(string storeId, string paymentRequestId,
UpdatePaymentRequestRequest request, CancellationToken token = default)
public virtual async Task<PaymentRequestBaseData> UpdatePaymentRequest(string storeId, string paymentRequestId,
PaymentRequestBaseData request, CancellationToken token = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
return await SendHttpRequest<PaymentRequestData>($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", request, HttpMethod.Put, token);
return await SendHttpRequest<PaymentRequestBaseData>($"api/v1/stores/{storeId}/payment-requests/{paymentRequestId}", request, HttpMethod.Put, token);
}
}

View File

@@ -1,6 +0,0 @@
namespace BTCPayServer.Client.Models
{
public class CreatePaymentRequestRequest : PaymentRequestBaseData
{
}
}

View File

@@ -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<string, JToken> 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<string, JToken> AdditionalData { get; set; }
}
}

View File

@@ -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
}
}
}

View File

@@ -1,6 +0,0 @@
namespace BTCPayServer.Client.Models
{
public class UpdatePaymentRequestRequest : PaymentRequestBaseData
{
}
}

View File

@@ -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

View File

@@ -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<DateTime>()));
Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None);
var date = NBitcoin.Utils.UnixTimeToDateTime(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value<DateTime>()));
jobj.Remove("expiryDate");
Expiry = date;
}
else if (jobj["expiryDate"]?.Type == JTokenType.Integer)
{
var date = NBitcoin.Utils.UnixTimeToDateTime(jobj["expiryDate"].Value<long>());
jobj.Remove("expiryDate");
Expiry = date;
}
Currency = jobj["currency"].Value<string>();
Amount = jobj["amount"] switch
{
JValue jv when jv.Type == JTokenType.Float => jv.Value<decimal>(),
JValue jv when jv.Type == JTokenType.Integer => jv.Value<long>(),
JValue jv when jv.Type == JTokenType.String && decimal.TryParse(jv.Value<string>(), CultureInfo.InvariantCulture, out var d) => d,
_ => 0m
};
Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None);
return true;
}
}

View File

@@ -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<PaymentRequestData>()
.Property(o => o.Blob2)
.HasColumnType("JSONB");
builder.Entity<PaymentRequestData>()
.Property(p => p.Status)
.HasConversion<string>();
}
}
}

View File

@@ -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
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "Expiry",
table: "PaymentRequests",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Amount",
table: "PaymentRequests",
type: "numeric",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
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;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -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<string>("Id")
.HasColumnType("text");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<bool>("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<int>("Status")
.HasColumnType("integer");
b.Property<string>("Currency")
.HasColumnType("text");
b.Property<DateTimeOffset?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StoreDataId")
.HasColumnType("text");

View File

@@ -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;

View File

@@ -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)

View File

@@ -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<UpdatePaymentRequestRequest>();
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<string>(Assert.IsType<OkObjectResult>(await user.GetController<UIPaymentRequestController>()
.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,

View File

@@ -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<ApplicationDbContextFactory>().CreateContext();
var migrator = serverTester.PayTester.GetService<PaymentRequestsMigratorHostedService>();
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<PaymentMethodHandlerDictionary>();
await acc.ImportOldInvoices();
var dbContext = tester.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
var invoiceMigrator = tester.PayTester.GetService<InvoiceBlobMigratorHostedService>();
invoiceMigrator.BatchSize = 2;
@@ -3164,9 +3286,11 @@ namespace BTCPayServer.Tests
// Quick unrelated test on GetMonitoredInvoices
var invoiceRepo = tester.PayTester.GetService<InvoiceRepository>();
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);
Assert.Single(monitored.Payments);
#pragma warning restore CS0618 // Type or member is obsolete
//
app = await client.CreatePointOfSaleApp(acc.StoreId, new PointOfSaleAppRequest

View File

@@ -17,6 +17,9 @@ 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
@@ -53,7 +56,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-requests")]
public async Task<ActionResult<IEnumerable<Client.Models.PaymentRequestData>>> GetPaymentRequests(string storeId, bool includeArchived = false)
public async Task<ActionResult<IEnumerable<Client.Models.PaymentRequestBaseData>>> GetPaymentRequests(string storeId, bool includeArchived = false)
{
var prs = await _paymentRequestRepository.FindPaymentRequests(
new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = includeArchived });
@@ -149,26 +152,80 @@ namespace BTCPayServer.Controllers.Greenfield
}
[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<IActionResult> CreatePaymentRequest(string storeId,
CreatePaymentRequestRequest request)
public async Task<IActionResult> CreateOrUpdatePaymentRequest(
[FromRoute] string storeId,
PaymentRequestBaseData request,
[FromRoute] string paymentRequestId = null)
{
var validationResult = await Validate(null, request);
if (validationResult != null)
if (request is null)
return BadRequest();
if (request.Amount <= 0)
{
return validationResult;
ModelState.AddModelError(nameof(request.Amount), "Please provide an amount greater than 0");
}
request.Currency ??= StoreData.GetStoreBlob().DefaultCurrency;
var pr = new PaymentRequestData()
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");
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.PaymentRequestData.PaymentRequestStatus.Pending,
Created = DateTimeOffset.UtcNow
Status = Client.Models.PaymentRequestStatus.Pending,
Created = DateTimeOffset.UtcNow,
Amount = request.Amount,
Currency = request.Currency ?? StoreData.GetStoreBlob().DefaultCurrency,
Expiry = request.ExpiryDate,
};
request.FormResponse = null;
request.StoreId = storeId;
pr.SetBlob(request);
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
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));
}
@@ -176,80 +233,23 @@ namespace BTCPayServer.Controllers.Greenfield
public PaymentRequestService PaymentRequestService { get; }
[HttpPut("~/api/v1/stores/{storeId}/payment-requests/{paymentRequestId}")]
[Authorize(Policy = Policies.CanModifyPaymentRequests,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> 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<IActionResult> 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)
private static Client.Models.PaymentRequestBaseData FromModel(PaymentRequestData data)
{
var blob = data.GetBlob();
return new Client.Models.PaymentRequestData()
return new Client.Models.PaymentRequestBaseData()
{
CreatedTime = data.Created,
Id = data.Id,
StoreId = data.StoreDataId,
Status = data.Status,
Archived = data.Archived,
Amount = blob.Amount,
Currency = blob.Currency,
Amount = data.Amount,
Currency = data.Currency,
Description = blob.Description,
Title = blob.Title,
ExpiryDate = blob.ExpiryDate,
ExpiryDate = data.Expiry,
Email = blob.Email,
AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts,
FormResponse = blob.FormResponse,

View File

@@ -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<ApiHealthData>(GetController<GreenfieldHealthController>().GetHealth()));
}
public override async Task<IEnumerable<PaymentRequestData>> GetPaymentRequests(string storeId,
public override async Task<IEnumerable<PaymentRequestBaseData>> GetPaymentRequests(string storeId,
bool includeArchived = false, CancellationToken token = default)
{
return GetFromActionResult(await GetController<GreenfieldPaymentRequestsController>().GetPaymentRequests(storeId, includeArchived));
}
public override async Task<PaymentRequestData> GetPaymentRequest(string storeId, string paymentRequestId,
public override async Task<PaymentRequestBaseData> GetPaymentRequest(string storeId, string paymentRequestId,
CancellationToken token = default)
{
return GetFromActionResult<PaymentRequestData>(
return GetFromActionResult<PaymentRequestBaseData>(
await GetController<GreenfieldPaymentRequestsController>().GetPaymentRequest(storeId, paymentRequestId));
}
@@ -621,19 +620,19 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldPaymentRequestsController>().PayPaymentRequest(storeId, paymentRequestId, request, token));
}
public override async Task<PaymentRequestData> CreatePaymentRequest(string storeId,
CreatePaymentRequestRequest request, CancellationToken token = default)
public override async Task<PaymentRequestBaseData> CreatePaymentRequest(string storeId,
PaymentRequestBaseData request, CancellationToken token = default)
{
return GetFromActionResult<PaymentRequestData>(
await GetController<GreenfieldPaymentRequestsController>().CreatePaymentRequest(storeId, request));
return GetFromActionResult<PaymentRequestBaseData>(
await GetController<GreenfieldPaymentRequestsController>().CreateOrUpdatePaymentRequest(storeId, request));
}
public override async Task<PaymentRequestData> UpdatePaymentRequest(string storeId, string paymentRequestId,
UpdatePaymentRequestRequest request,
public override async Task<PaymentRequestBaseData> UpdatePaymentRequest(string storeId, string paymentRequestId,
PaymentRequestBaseData request,
CancellationToken token = default)
{
return GetFromActionResult<PaymentRequestData>(
await GetController<GreenfieldPaymentRequestsController>().UpdatePaymentRequest(storeId, paymentRequestId, request));
return GetFromActionResult<PaymentRequestBaseData>(
await GetController<GreenfieldPaymentRequestsController>().CreateOrUpdatePaymentRequest(storeId, request, paymentRequestId));
}
public override async Task<ApiKeyData> GetCurrentAPIKeyInfo(CancellationToken token = default)

View File

@@ -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 }

View File

@@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers
StoreId = store.Id,
Skip = model.Skip,
Count = model.Count,
Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse<Client.Models.PaymentRequestData.PaymentRequestStatus>(s, true)).ToArray(),
Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse<Client.Models.PaymentRequestStatus>(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

View File

@@ -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; }
}
}

View File

@@ -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<PaymentRequestBaseData>().GetBlob() ?? new PaymentRequestBaseData();
(DefaultSerializerSettings, DefaultSerializer) = BlobSerializer.CreateSerializer(null as NBitcoin.Network);
}
public static PaymentRequestBlob GetBlob(this PaymentRequestData paymentRequestData)
{
return paymentRequestData.HasTypedBlob<PaymentRequestBlob>().GetBlob(DefaultSerializerSettings) ?? new PaymentRequestBlob();
}
public static void SetBlob(this PaymentRequestData paymentRequestData, PaymentRequestBaseData blob)
public static void SetBlob(this PaymentRequestData paymentRequestData, PaymentRequestBlob blob)
{
paymentRequestData.HasTypedBlob<PaymentRequestBaseData>().SetBlob(blob);
paymentRequestData.HasTypedBlob<PaymentRequestBlob>().SetBlob(blob, DefaultSerializer);
}
}
}

View File

@@ -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<PaymentRequestData>
{
private readonly PaymentRequestStreamer _paymentRequestStreamer;
public PaymentRequestsMigratorHostedService(
ILogger<PaymentRequestsMigratorHostedService> 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<PaymentRequestData> 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<PaymentRequestData> 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;

View File

@@ -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;

View File

@@ -92,6 +92,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<LocalizerService>();
services.TryAddSingleton<ViewLocalizer>();
services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("",""));
services.TryAddSingleton<DelayedTaskScheduler>();
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
@@ -431,7 +432,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>();
services.AddSingleton<PaymentRequestStreamer>();
services.AddSingleton<IHostedService>(s => s.GetRequiredService<PaymentRequestStreamer>());
services.AddSingleton<IBackgroundJobClient, BackgroundJobClient>();
services.AddScoped<IAuthorizationHandler, CookieAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();

View File

@@ -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:

View File

@@ -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<PaymentRequestHub> 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<InvoiceEvent>();
Subscribe<PaymentRequestUpdated>();
Subscribe<PaymentRequestEvent>();
}
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
{
var delay = expiry - DateTime.UtcNow;
if (delay > TimeSpan.Zero)
await Task.Delay(delay, cancellationToken);
await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentRequestId);
}, cancellationToken);
Status: PaymentRequestStatus.Pending or PaymentRequestStatus.Processing,
Expiry: { } e
})
{
_delayedTaskScheduler.Schedule($"PAYREQ_{data.Id}", e, async () =>
{
await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(data.Id);
});
}
}
private async Task InfoUpdated(string paymentRequestId)

View File

@@ -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,

View File

@@ -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<DelayedTaskScheduler> logger)
{
Logger = logger;
}
record class TimerState(string Key, Func<Task> Act);
private readonly Dictionary<string, Timer> _timers = new();
bool disposed = false;
public ILogger<DelayedTaskScheduler> Logger { get; }
public void Schedule(string key, DateTimeOffset executeAt, Func<Task> 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();
}
}
}
}

View File

@@ -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<PaymentRequestData>(paymentRequestId);
@@ -113,7 +113,17 @@ namespace BTCPayServer.Services.PaymentRequests
Type = PaymentRequestEvent.StatusChanged
});
}
public async Task<PaymentRequestData[]> 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<PaymentRequestData[]> 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; }