Prune webhook data from database

This commit is contained in:
nicolas.dorier 2023-05-28 23:44:10 +09:00 committed by Andrew Camilleri
parent 418b476725
commit 4e03c2523a
21 changed files with 515 additions and 161 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Data.Common;
using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
@ -21,7 +22,6 @@ namespace BTCPayServer.Abstractions.Contracts
}
public abstract T CreateContext();
class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator
{
#pragma warning disable EF1001 // Internal EF Core API usage.

View file

@ -51,7 +51,8 @@ namespace BTCPayServer.Client
{
if (message.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
{
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(await message.Content.ReadAsStringAsync());
var aa = await message.Content.ReadAsStringAsync();
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(aa);
throw new GreenfieldValidationException(err);
}
if (message.StatusCode == System.Net.HttpStatusCode.Forbidden)

View file

@ -51,6 +51,10 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset Timestamp { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
public bool IsPruned()
{
return DeliveryId is null;
}
public T ReadAs<T>()
{
var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings);

View file

@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
public class WebhookDeliveryData : IHasBlobUntyped
public class WebhookDeliveryData
{
[Key]
[MaxLength(25)]
@ -17,10 +17,8 @@ namespace BTCPayServer.Data
[Required]
public DateTimeOffset Timestamp { get; set; }
[Obsolete("Use Blob2 instead")]
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
public string Blob { get; set; }
public bool Pruned { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
@ -28,11 +26,11 @@ namespace BTCPayServer.Data
.HasOne(o => o.Webhook)
.WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade);
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.WebhookId);
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.Timestamp);
if (databaseFacade.IsNpgsql())
{
builder.Entity<WebhookDeliveryData>()
.Property(o => o.Blob2)
.Property(o => o.Blob)
.HasColumnType("JSONB");
}
}

View file

@ -0,0 +1,83 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NBitcoin;
using Newtonsoft.Json;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230529135505_WebhookDeliveriesCleanup")]
public partial class WebhookDeliveriesCleanup : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder.IsNpgsql())
{
migrationBuilder.Sql("DROP TABLE IF EXISTS \"InvoiceWebhookDeliveries\", \"WebhookDeliveries\";");
migrationBuilder.CreateTable(
name: "WebhookDeliveries",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
WebhookId = table.Column<string>(type: "TEXT", nullable: false),
Timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Pruned = table.Column<bool>(type: "BOOLEAN", nullable: false),
Blob = table.Column<string>(type: "JSONB", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WebhookDeliveries", x => x.Id);
table.ForeignKey(
name: "FK_WebhookDeliveries_Webhooks_WebhookId",
column: x => x.WebhookId,
principalTable: "Webhooks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_WebhookDeliveries_WebhookId",
table: "WebhookDeliveries",
column: "WebhookId");
migrationBuilder.Sql("CREATE INDEX \"IX_WebhookDeliveries_Timestamp\" ON \"WebhookDeliveries\"(\"Timestamp\") WHERE \"Pruned\" IS FALSE");
migrationBuilder.CreateTable(
name: "InvoiceWebhookDeliveries",
columns: table => new
{
InvoiceId = table.Column<string>(type: "TEXT", nullable: false),
DeliveryId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceWebhookDeliveries", x => new { x.InvoiceId, x.DeliveryId });
table.ForeignKey(
name: "FK_InvoiceWebhookDeliveries_WebhookDeliveries_DeliveryId",
column: x => x.DeliveryId,
principalTable: "WebhookDeliveries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_InvoiceWebhookDeliveries_Invoices_InvoiceId",
column: x => x.InvoiceId,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View file

@ -1019,12 +1019,12 @@ namespace BTCPayServer.Migrations
.HasMaxLength(25)
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<string>("Blob2")
b.Property<string>("Blob")
.HasColumnType("TEXT");
b.Property<bool>("Pruned")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
@ -1035,6 +1035,8 @@ namespace BTCPayServer.Migrations
b.HasKey("Id");
b.HasIndex("Timestamp");
b.HasIndex("WebhookId");
b.ToTable("WebhookDeliveries");

View file

@ -1432,7 +1432,7 @@ namespace BTCPayServer.Tests
Assert.False(hook.AutomaticRedelivery);
Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url);
}
using var tester = CreateServerTester();
using var tester = CreateServerTester(newDb: true);
using var fakeServer = new FakeServer();
await fakeServer.Start();
await tester.StartAsync();
@ -1509,6 +1509,14 @@ namespace BTCPayServer.Tests
clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice);
await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
TestLogs.LogInformation("Can prune deliveries");
var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
cleanup.BatchSize = 1;
cleanup.PruneAfter = TimeSpan.Zero;
await cleanup.Do(default);
await AssertHttpError(409, () => clientProfile.RedeliverWebhook(user.StoreId, hook.Id, delivery.Id));
TestLogs.LogInformation("Testing corner cases");
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", newDeliveryId));
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, "lol"));

View file

@ -153,15 +153,24 @@ namespace BTCPayServer.Controllers.Greenfield
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
if (delivery is null)
return WebhookDeliveryNotFound();
if (delivery.GetBlob().IsPruned())
return WebhookDeliveryPruned();
return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId)));
}
private IActionResult WebhookDeliveryPruned()
{
return this.CreateAPIError(409, "webhookdelivery-pruned", "This webhook delivery has been pruned, so it can't be redelivered");
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
{
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
if (delivery is null)
return WebhookDeliveryNotFound();
if (delivery.GetBlob().IsPruned())
return WebhookDeliveryPruned();
return File(delivery.GetBlob().Request, "application/json");
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@ -29,12 +30,30 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookDeliveryStatus Status { get; set; }
public int? HttpCode { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string ErrorMessage { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public byte[] Request { get; set; }
public void Prune()
{
var request = JObject.Parse(UTF8Encoding.UTF8.GetString(Request));
foreach (var prop in request.Properties().ToList())
{
if (prop.Name == "type")
continue;
prop.Remove();
}
Request = UTF8Encoding.UTF8.GetBytes(request.ToString(Formatting.None));
}
public T ReadRequestAs<T>()
{
return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings);
}
public bool IsPruned()
{
return ReadRequestAs<WebhookEvent>().IsPruned();
}
}
public class WebhookBlob
{
@ -56,11 +75,17 @@ namespace BTCPayServer.Data
}
public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook)
{
return webhook.HasTypedBlob<WebhookDeliveryBlob>().GetBlob(HostedServices.WebhookSender.DefaultSerializerSettings);
if (webhook.Blob is null)
return null;
else
return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, HostedServices.WebhookSender.DefaultSerializerSettings);
}
public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
{
webhook.HasTypedBlob<WebhookDeliveryBlob>().SetBlob(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
if (blob is null)
webhook.Blob = null;
else
webhook.Blob = JsonConvert.SerializeObject(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
}
}
}

View file

@ -13,6 +13,7 @@ using System.Threading.Tasks;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
using BTCPayServer.Models;
@ -25,6 +26,7 @@ using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
@ -115,6 +117,14 @@ namespace BTCPayServer
return value;
}
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
where T : class, IPeriodicTask
{
services.AddSingleton<T>();
services.AddTransient<ScheduledTask>(o => new ScheduledTask(typeof(T), every));
return services;
}
public static PaymentMethodId GetpaymentMethodId(this InvoiceCryptoInfo info)
{
return new PaymentMethodId(info.CryptoCode, PaymentTypes.Parse(info.PaymentType));

View file

@ -0,0 +1,61 @@
using System;
using Dapper;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
namespace BTCPayServer.HostedServices
{
public class CleanupWebhookDeliveriesTask : IPeriodicTask
{
public CleanupWebhookDeliveriesTask(ApplicationDbContextFactory dbContextFactory)
{
DbContextFactory = dbContextFactory;
}
public ApplicationDbContextFactory DbContextFactory { get; }
public int BatchSize { get; set; } = 500;
public TimeSpan PruneAfter { get; set; } = TimeSpan.FromDays(60);
public async Task Do(CancellationToken cancellationToken)
{
await using var ctx = DbContextFactory.CreateContext();
if (!ctx.Database.IsNpgsql())
return;
var conn = ctx.Database.GetDbConnection();
bool pruned = false;
int offset = 0;
retry:
var rows = await conn.QueryAsync<WebhookDeliveryData>(@"
SELECT ""Id"", ""Blob""
FROM ""WebhookDeliveries""
WHERE ((now() - ""Timestamp"") > @PruneAfter) AND ""Pruned"" IS FALSE
ORDER BY ""Timestamp""
LIMIT @BatchSize OFFSET @offset
", new { PruneAfter, BatchSize, offset });
foreach (var d in rows)
{
var blob = d.GetBlob();
blob.Prune();
d.SetBlob(blob);
d.Pruned = true;
pruned = true;
}
if (pruned)
{
pruned = false;
await conn.ExecuteAsync("UPDATE \"WebhookDeliveries\" SET \"Blob\"=@Blob::JSONB, \"Pruned\"=@Pruned WHERE \"Id\"=@Id", rows);
if (rows.Count() == BatchSize)
{
offset += BatchSize;
goto retry;
}
}
}
}
}

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
using System.Threading;
namespace BTCPayServer.HostedServices
{
public interface IPeriodicTask
{
Task Do(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,92 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace BTCPayServer.HostedServices
{
public class PeriodicTaskLauncherHostedService : IHostedService
{
public PeriodicTaskLauncherHostedService(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
{
ServiceProvider = serviceProvider;
Logger = loggerFactory.CreateLogger("BTCPayServer.PeriodicTasks");
}
public IServiceProvider ServiceProvider { get; }
public ILogger Logger { get; }
Channel<ScheduledTask> jobs = Channel.CreateBounded<ScheduledTask>(100);
CancellationTokenSource cts;
public Task StartAsync(CancellationToken cancellationToken)
{
cts = new CancellationTokenSource();
foreach (var task in ServiceProvider.GetServices<ScheduledTask>())
jobs.Writer.TryWrite(task);
loop = Task.WhenAll(Enumerable.Range(0, 3).Select(_ => Loop(cts.Token)).ToArray());
return Task.CompletedTask;
}
Task loop;
private async Task Loop(CancellationToken token)
{
try
{
await foreach (var job in jobs.Reader.ReadAllAsync(token))
{
if (job.NextScheduled <= DateTimeOffset.UtcNow)
{
var t = (IPeriodicTask)ServiceProvider.GetService(job.PeriodicTaskType);
try
{
await t.Do(token);
}
catch when (token.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, $"Unhandled error in periodic task {job.PeriodicTaskType.Name}");
}
finally
{
job.NextScheduled = DateTimeOffset.UtcNow + job.Every;
}
}
_ = Wait(job, token);
}
}
catch when (token.IsCancellationRequested)
{
}
}
private async Task Wait(ScheduledTask job, CancellationToken token)
{
var timeToWait = job.NextScheduled - DateTimeOffset.UtcNow;
try
{
await Task.Delay(timeToWait, token);
}
catch { }
while (await jobs.Writer.WaitToWriteAsync())
{
if (jobs.Writer.TryWrite(job))
break;
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
cts?.Cancel();
jobs.Writer.TryComplete();
if (loop is not null)
await loop;
}
}
}

View file

@ -0,0 +1,16 @@
using System;
namespace BTCPayServer.HostedServices
{
public class ScheduledTask
{
public ScheduledTask(Type periodicTypeTask, TimeSpan every)
{
PeriodicTaskType = periodicTypeTask;
Every = every;
}
public Type PeriodicTaskType { get; set; }
public TimeSpan Every { get; set; } = TimeSpan.FromMinutes(5.0);
public DateTimeOffset NextScheduled { get; set; } = DateTimeOffset.UtcNow;
}
}

View file

@ -105,6 +105,8 @@ namespace BTCPayServer.HostedServices
var newDeliveryBlob = new WebhookDeliveryBlob();
newDeliveryBlob.Request = oldDeliveryBlob.Request;
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
if (webhookEvent.IsPruned())
return null;
webhookEvent.DeliveryId = newDelivery.Id;
webhookEvent.WebhookId = webhookDelivery.Webhook.Id;
// if we redelivered a redelivery, we still want the initial delivery here

View file

@ -350,6 +350,9 @@ namespace BTCPayServer.Hosting
services.AddSingleton<HostedServices.WebhookSender>();
services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)

View file

@ -213,6 +213,9 @@ namespace BTCPayServer.Hosting
{
var typeMapping = t.EntityTypeMappings.Single();
var query = (IQueryable<object>)otherContext.GetType().GetMethod("Set", new Type[0])!.MakeGenericMethod(typeMapping.EntityType.ClrType).Invoke(otherContext, null)!;
if (t.Name == "WebhookDeliveries" ||
t.Name == "InvoiceWebhookDeliveries")
continue;
Logger.LogInformation($"Migrating table: " + t.Name);
List<PropertyInfo> datetimeProperties = new List<PropertyInfo>();
foreach (var col in t.Columns)

View file

@ -22,13 +22,16 @@ namespace BTCPayServer.Models.StoreViewModels
Success = blob.Status == WebhookDeliveryStatus.HttpSuccess;
ErrorMessage = blob.ErrorMessage ?? "Success";
Time = s.Timestamp;
Type = blob.ReadRequestAs<WebhookEvent>().Type;
var evt = blob.ReadRequestAs<WebhookEvent>();
Type = evt.Type;
Pruned = evt.IsPruned();
WebhookId = s.Id;
PayloadUrl = s.Webhook?.GetBlob().Url;
}
public string Id { get; set; }
public DateTimeOffset Time { get; set; }
public WebhookEventType Type { get; private set; }
public bool Pruned { get; set; }
public string WebhookId { get; set; }
public bool Success { get; set; }
public string ErrorMessage { get; set; }

View file

@ -9,16 +9,26 @@
@section PageHeadContent {
<meta name="robots" content="noindex,nofollow">
<style>
#posData td > table:last-child { margin-bottom: 0 !important; }
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
.invoice-information { display: flex; flex-wrap: wrap; gap: var(--btcpay-space-xl) var(--btcpay-space-xxl); }
#posData td > table:last-child {
margin-bottom: 0 !important;
}
#posData table > tbody > tr:first-child > td > h4 {
margin-top: 0 !important;
}
.invoice-information {
display: flex;
flex-wrap: wrap;
gap: var(--btcpay-space-xl) var(--btcpay-space-xxl);
}
</style>
}
@section PageFootContent {
<script>
const alertClasses = { "Settled (marked)": 'success', "Invalid (marked)": 'danger' }
function changeInvoiceState(invoiceId, newState) {
console.log(invoiceId, newState)
const toggleButton = $("#markStatusDropdownMenuButton");
@ -34,13 +44,13 @@
alert("Invoice state update failed");
});
}
delegate('click', '[data-change-invoice-status-button]', e => {
const button = e.target.closest('[data-change-invoice-status-button]')
const { id, status } = button.dataset
changeInvoiceState(id, status)
})
const handleRefundResponse = async response => {
const modalBody = document.querySelector('#RefundModal .modal-body')
if (response.ok && response.redirected) {
@ -51,14 +61,14 @@
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">Failed to load refund options.</div>'
}
}
delegate('click', '#IssueRefund', async e => {
e.preventDefault()
const { href: url } = e.target
const response = await fetch(url)
await handleRefundResponse(response)
})
delegate('submit', '#RefundForm', async e => {
e.preventDefault()
const form = e.target
@ -67,7 +77,7 @@
const response = await fetch(url, { method, body })
await handleRefundResponse(response)
})
function checkCustomAmount() {
const $refundForm = document.getElementById('RefundForm');
const currency = $refundForm.querySelector('#CustomCurrency').value;
@ -77,7 +87,7 @@
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
let isOverpaying = false;
if (currency === cryptoCode) {
isOverpaying = amount > Math.max(cryptoAmountNow, cryptoAmountThen);
@ -88,7 +98,7 @@
}
delegate('change', '#CustomAmount', checkCustomAmount);
delegate('change', '#CustomCurrency', checkCustomAmount);
function updateSubtractPercentageResult() {
const $refundForm = document.getElementById('RefundForm');
const $result = document.getElementById('SubtractPercentageResult');
@ -97,7 +107,7 @@
$result.hidden = true;
return;
}
const refundOption = $selectedRefundOption.value;
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
const customCurrency = $refundForm.querySelector('#CustomCurrency').value;
@ -111,7 +121,7 @@
const invoiceDivisibility = parseInt($refundForm.querySelector('#InvoiceDivisibility').value);
const percentage = parseFloat($refundForm.querySelector('#SubtractPercentage').value);
const isInvalid = isNaN(percentage);
let amount = null;
let currency = cryptoCode;
let divisibility = cryptoDivisibility;
@ -136,12 +146,12 @@
divisibility = customCurrency === invoiceCurrency ? invoiceDivisibility : cryptoDivisibility;
break;
}
if (amount == null || isInvalid) {
$result.hidden = true;
return;
}
console.log({ refundOption, isInvalid, amount, currency })
const reduceByAmount = (amount * (percentage / 100));
const refundAmount = (amount - reduceByAmount).toFixed(divisibility);
@ -163,7 +173,7 @@
<div class="modal-header">
<h4 class="modal-title" id="RefundTitle">Issue Refund</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body">
@ -211,7 +221,7 @@
</div>
</div>
<partial name="_StatusMessage"/>
<partial name="_StatusMessage" />
<div class="invoice-details">
<div class="invoice-information mb-5">
@ -341,58 +351,59 @@
</table>
</div>
<div class="d-flex flex-column gap-5">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) ||
Model.TypedMetadata.TaxIncluded is not null)
{
<div>
<h3 class="mb-3">
<span>Product Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<table class="table mb-0">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
{
<tr>
<th class="fw-semibold">Item code</th>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
<tr>
<th class="fw-semibold">Item Description</th>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
}
@if (Model.TaxIncluded is not null)
{
<tr>
<th class="fw-semibold">Tax Included</th>
<td>@Model.TaxIncluded</td>
</tr>
}
</table>
</div>
}
@if (Model.TypedMetadata.BuyerName is not null ||
Model.TypedMetadata.BuyerEmail is not null ||
Model.TypedMetadata.BuyerPhone is not null ||
Model.TypedMetadata.BuyerAddress1 is not null ||
Model.TypedMetadata.BuyerAddress2 is not null ||
Model.TypedMetadata.BuyerCity is not null ||
Model.TypedMetadata.BuyerState is not null ||
Model.TypedMetadata.BuyerCountry is not null ||
Model.TypedMetadata.BuyerZip is not null)
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) ||
Model.TypedMetadata.TaxIncluded is not null)
{
<div>
<h3 class="mb-3"><span>Buyer Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<h3 class="mb-3">
<span>Product Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<table class="table mb-0">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
{
<tr>
<th class="fw-semibold">Item code</th>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
<tr>
<th class="fw-semibold">Item Description</th>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
}
@if (Model.TaxIncluded is not null)
{
<tr>
<th class="fw-semibold">Tax Included</th>
<td>@Model.TaxIncluded</td>
</tr>
}
</table>
</div>
}
@if (Model.TypedMetadata.BuyerName is not null ||
Model.TypedMetadata.BuyerEmail is not null ||
Model.TypedMetadata.BuyerPhone is not null ||
Model.TypedMetadata.BuyerAddress1 is not null ||
Model.TypedMetadata.BuyerAddress2 is not null ||
Model.TypedMetadata.BuyerCity is not null ||
Model.TypedMetadata.BuyerState is not null ||
Model.TypedMetadata.BuyerCountry is not null ||
Model.TypedMetadata.BuyerZip is not null)
{
<div>
<h3 class="mb-3">
<span>Buyer Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<table class="table mb-0">
@if (Model.TypedMetadata.BuyerName is not null)
{
@ -465,12 +476,12 @@
@if (Model.AdditionalData.Any())
{
<div>
<h3 class="mb-3">
<span>Additional Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<h3 class="mb-3">
<span>Additional Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<partial name="PosData" model="(Model.AdditionalData, 1)" />
</div>
}
@ -478,7 +489,7 @@
</div>
<h3 class="mb-3">Invoice Summary</h3>
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)"/>
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
@if (Model.Deliveries.Any())
{
@ -487,46 +498,49 @@
<div class="table-responsive-xl">
<table class="table table-hover mb-5">
<thead>
<tr>
<th>Status</th>
<th>ID</th>
<th>Type</th>
<th>Url</th>
<th>Date</th>
<th class="text-end">Action</th>
</tr>
<tr>
<th>Status</th>
<th>ID</th>
<th>Type</th>
<th>Url</th>
<th>Date</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
@foreach (var delivery in Model.Deliveries)
{
<tr>
<form asp-action="RedeliverWebhook"
asp-route-storeId="@Model.StoreId"
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
method="post">
@foreach (var delivery in Model.Deliveries)
{
<tr>
<form asp-action="RedeliverWebhook"
asp-route-storeId="@Model.StoreId"
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
method="post">
<td>
<span>
@if (delivery.Success)
{
@if (delivery.Success)
{
<span class="fa fa-check text-success" title="Success"></span>
}
else
{
}
else
{
<span class="fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
}
}
</span>
</td>
<td>
@if (!delivery.Pruned)
{
<span>
<a asp-action="WebhookDelivery"
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
class="delivery-content"
target="_blank">
@delivery.Id
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
class="delivery-content"
target="_blank">
@delivery.Id
</a>
</span>
}
</td>
<td>
<span>@delivery.Type</span>
@ -538,24 +552,26 @@
</td>
<td>
<span>
@delivery.Time.ToBrowserDate()
@delivery.Time.ToBrowserDate()
</span>
</td>
<td class="text-end">
@if (!delivery.Pruned) {
<button id="#redeliver-@delivery.Id"
type="submit"
class="btn btn-link p-0 redeliver">
Redeliver
</button>
}
</td>
</form>
</tr>
}
</form>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if ((Model.Refunds?.Count ?? 0) > 0)
{
@ -564,36 +580,36 @@
<div class="table-responsive-xl">
<table class="table table-hover mb-5">
<thead>
<tr>
<th>Pull Payment</th>
<th>Amount</th>
<th>Date</th>
</tr>
<tr>
<th>Pull Payment</th>
<th>Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@foreach (var refund in Model.Refunds)
{
var blob = refund.PullPaymentData.GetBlob();
<tr>
@foreach (var refund in Model.Refunds)
{
var blob = refund.PullPaymentData.GetBlob();
<tr>
<td>
<span>
<a asp-action="ViewPullPayment" asp-controller="UIPullPayment"
asp-route-pullPaymentId="@refund.PullPaymentDataId"
class="delivery-content"
target="_blank">
@refund.PullPaymentData.Id
</a>
</span>
</td>
<td>
<span>@blob.Limit @blob.Currency</span>
</td>
<td>
<span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span>
</td>
</tr>
}
<td>
<span>
<a asp-action="ViewPullPayment" asp-controller="UIPullPayment"
asp-route-pullPaymentId="@refund.PullPaymentDataId"
class="delivery-content"
target="_blank">
@refund.PullPaymentData.Id
</a>
</span>
</td>
<td>
<span>@blob.Limit @blob.Currency</span>
</td>
<td>
<span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span>
</td>
</tr>
}
</tbody>
</table>
</div>
@ -603,19 +619,19 @@
<h3 class="mb-0">Events</h3>
<table class="table table-hover mt-3 mb-4">
<thead>
<tr>
<th>Date</th>
<th>Message</th>
</tr>
<tr>
<th>Date</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var evt in Model.Events)
{
<tr class="text-@evt.GetCssClass()">
<td>@evt.Timestamp.ToBrowserDate()</td>
<td>@evt.Message</td>
</tr>
}
@foreach (var evt in Model.Events)
{
<tr class="text-@evt.GetCssClass()">
<td>@evt.Timestamp.ToBrowserDate()</td>
<td>@evt.Message</td>
</tr>
}
</tbody>
</table>
</section>

View file

@ -104,6 +104,7 @@
{
<span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
}
@if (!delivery.Pruned) {
<span class="ms-3">
<a asp-action="WebhookDelivery"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
@ -113,6 +114,7 @@
@delivery.Id
</a>
</span>
}
</span>
<span class="d-flex align-items-center">
<strong class="d-flex align-items-center text-muted small">

View file

@ -397,6 +397,9 @@
},
"404": {
"description": "The delivery does not exists."
},
"409": {
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
}
},
"security": [
@ -459,6 +462,9 @@
},
"404": {
"description": "The delivery does not exists."
},
"409": {
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
}
},
"security": [