Pluginize Webhooks and support Payouts (#5421)

Co-authored-by: d11n <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri 2023-12-01 10:50:05 +01:00 committed by GitHub
parent 605741182d
commit a97172cea6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1265 additions and 706 deletions

3
.gitignore vendored
View file

@ -298,4 +298,5 @@ Packed Plugins
Plugins/packed
BTCPayServer/wwwroot/swagger/v1/openapi.json
BTCPayServer/appsettings.dev.json
BTCPayServer/appsettings.dev.json
BTCPayServer.Tests/monero_wallet

View file

@ -11,8 +11,7 @@ namespace BTCPayServer.Client.Models
{
public bool Everything { get; set; } = true;
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>();
public string[] SpecificEvents { get; set; } = Array.Empty<string>();
}
public bool Enabled { get; set; } = true;

View file

@ -9,7 +9,7 @@ namespace BTCPayServer.Client.Models
{
public class WebhookEvent
{
public readonly static JsonSerializerSettings DefaultSerializerSettings;
public static readonly JsonSerializerSettings DefaultSerializerSettings;
static WebhookEvent()
{
DefaultSerializerSettings = new JsonSerializerSettings();
@ -45,8 +45,7 @@ namespace BTCPayServer.Client.Models
}
}
public bool IsRedelivery { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public WebhookEventType Type { get; set; }
public string Type { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Timestamp { get; set; }
[JsonExtensionData]

View file

@ -1,13 +1,20 @@
namespace BTCPayServer.Client.Models
namespace BTCPayServer.Client.Models;
public static class WebhookEventType
{
public enum WebhookEventType
{
InvoiceCreated,
InvoiceReceivedPayment,
InvoiceProcessing,
InvoiceExpired,
InvoiceSettled,
InvoiceInvalid,
InvoicePaymentSettled,
}
public const string InvoiceCreated = nameof(InvoiceCreated);
public const string InvoiceReceivedPayment = nameof(InvoiceReceivedPayment);
public const string InvoiceProcessing = nameof(InvoiceProcessing);
public const string InvoiceExpired = nameof(InvoiceExpired);
public const string InvoiceSettled = nameof(InvoiceSettled);
public const string InvoiceInvalid = nameof(InvoiceInvalid);
public const string InvoicePaymentSettled = nameof(InvoicePaymentSettled);
public const string PayoutCreated = nameof(PayoutCreated);
public const string PayoutApproved = nameof(PayoutApproved);
public const string PayoutUpdated = nameof(PayoutUpdated);
public const string PaymentRequestUpdated = nameof(PaymentRequestUpdated);
public const string PaymentRequestCreated = nameof(PaymentRequestCreated);
public const string PaymentRequestArchived = nameof(PaymentRequestArchived);
public const string PaymentRequestStatusChanged = nameof(PaymentRequestStatusChanged);
}

View file

@ -1,31 +1,64 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
public class WebhookInvoiceEvent : WebhookEvent
public class WebhookPayoutEvent : StoreWebhookEvent
{
public WebhookPayoutEvent(string evtType, string storeId)
{
if (!evtType.StartsWith("payout", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(evtType));
Type = evtType;
StoreId = storeId;
}
[JsonProperty(Order = 2)] public string PayoutId { get; set; }
[JsonProperty(Order = 3)] public string PullPaymentId { get; set; }
[JsonProperty(Order = 4)] [JsonConverter(typeof(StringEnumConverter))]public PayoutState PayoutState { get; set; }
}
public class WebhookPaymentRequestEvent : StoreWebhookEvent
{
public WebhookPaymentRequestEvent(string evtType, string storeId)
{
if (!evtType.StartsWith("paymentrequest", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(evtType));
Type = evtType;
StoreId = storeId;
}
[JsonProperty(Order = 2)] public string PaymentRequestId { get; set; }
[JsonProperty(Order = 3)] [JsonConverter(typeof(StringEnumConverter))]public PaymentRequestData.PaymentRequestStatus Status { get; set; }
}
public abstract class StoreWebhookEvent : WebhookEvent
{
[JsonProperty(Order = 1)] public string StoreId { get; set; }
}
public class WebhookInvoiceEvent : StoreWebhookEvent
{
public WebhookInvoiceEvent()
{
}
public WebhookInvoiceEvent(WebhookEventType evtType)
{
this.Type = evtType;
public WebhookInvoiceEvent(string evtType, string storeId)
{
if (!evtType.StartsWith("invoice", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(evtType));
Type = evtType;
StoreId = storeId;
}
[JsonProperty(Order = 1)] public string StoreId { get; set; }
[JsonProperty(Order = 2)] public string InvoiceId { get; set; }
[JsonProperty(Order = 3)] public JObject Metadata { get; set; }
}
public class WebhookInvoiceSettledEvent : WebhookInvoiceEvent
{
public WebhookInvoiceSettledEvent()
{
}
public WebhookInvoiceSettledEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoiceSettledEvent(string storeId) : base(WebhookEventType.InvoiceSettled, storeId)
{
}
@ -34,11 +67,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceInvalidEvent : WebhookInvoiceEvent
{
public WebhookInvoiceInvalidEvent()
{
}
public WebhookInvoiceInvalidEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoiceInvalidEvent(string storeId) : base(WebhookEventType.InvoiceInvalid, storeId)
{
}
@ -47,11 +76,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceProcessingEvent : WebhookInvoiceEvent
{
public WebhookInvoiceProcessingEvent()
{
}
public WebhookInvoiceProcessingEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoiceProcessingEvent(string storeId) : base(WebhookEventType.InvoiceProcessing, storeId)
{
}
@ -60,11 +85,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceReceivedPaymentEvent : WebhookInvoiceEvent
{
public WebhookInvoiceReceivedPaymentEvent()
{
}
public WebhookInvoiceReceivedPaymentEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoiceReceivedPaymentEvent(string type, string storeId) : base(type, storeId)
{
}
@ -76,22 +97,14 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoicePaymentSettledEvent : WebhookInvoiceReceivedPaymentEvent
{
public WebhookInvoicePaymentSettledEvent()
{
}
public WebhookInvoicePaymentSettledEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoicePaymentSettledEvent(string storeId) : base(WebhookEventType.InvoicePaymentSettled, storeId)
{
}
}
public class WebhookInvoiceExpiredEvent : WebhookInvoiceEvent
{
public WebhookInvoiceExpiredEvent()
{
}
public WebhookInvoiceExpiredEvent(WebhookEventType evtType) : base(evtType)
public WebhookInvoiceExpiredEvent(string storeId) : base(WebhookEventType.InvoiceExpired, storeId)
{
}

View file

@ -2099,18 +2099,18 @@ namespace BTCPayServer.Tests
//validation errors
await AssertValidationError(new[] { nameof(CreateInvoiceRequest.Amount), $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentTolerance)}", $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentMethods)}[0]" }, async () =>
{
await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions() { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } });
await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } });
});
await AssertHttpError(403, async () =>
{
await viewOnly.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "helloinvalid", Amount = 1 });
new CreateInvoiceRequest { Currency = "helloinvalid", Amount = 1 });
});
await user.RegisterDerivationSchemeAsync("BTC");
string origOrderId = "testOrder";
var newInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest()
new CreateInvoiceRequest
{
Currency = "USD",
Amount = 1,
@ -2197,7 +2197,7 @@ namespace BTCPayServer.Tests
//list NonExisting Status
var invoicesNonExistingStatus = await viewOnly.GetInvoices(user.StoreId,
status: new[] { BTCPayServer.Client.Models.InvoiceStatus.Invalid });
status: new[] { InvoiceStatus.Invalid });
Assert.NotNull(invoicesNonExistingStatus);
Assert.Empty(invoicesNonExistingStatus);
@ -2215,7 +2215,7 @@ namespace BTCPayServer.Tests
//update
newInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 1 });
new CreateInvoiceRequest { Currency = "USD", Amount = 1 });
Assert.Contains(InvoiceStatus.Settled, newInvoice.AvailableStatusesForManualMarking);
Assert.Contains(InvoiceStatus.Invalid, newInvoice.AvailableStatusesForManualMarking);
await client.MarkInvoiceStatus(user.StoreId, newInvoice.Id, new MarkInvoiceStatusRequest()
@ -2227,7 +2227,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain(InvoiceStatus.Settled, newInvoice.AvailableStatusesForManualMarking);
Assert.Contains(InvoiceStatus.Invalid, newInvoice.AvailableStatusesForManualMarking);
newInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 1 });
new CreateInvoiceRequest { Currency = "USD", Amount = 1 });
await client.MarkInvoiceStatus(user.StoreId, newInvoice.Id, new MarkInvoiceStatusRequest()
{
Status = InvoiceStatus.Invalid
@ -2242,13 +2242,13 @@ namespace BTCPayServer.Tests
await AssertHttpError(403, async () =>
{
await viewOnly.UpdateInvoice(user.StoreId, invoice.Id,
new UpdateInvoiceRequest()
new UpdateInvoiceRequest
{
Metadata = metadataForUpdate
});
});
invoice = await client.UpdateInvoice(user.StoreId, invoice.Id,
new UpdateInvoiceRequest()
new UpdateInvoiceRequest
{
Metadata = metadataForUpdate
});
@ -2288,13 +2288,12 @@ namespace BTCPayServer.Tests
await client.UnarchiveInvoice(user.StoreId, invoice.Id);
Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id));
foreach (var marked in new[] { InvoiceStatus.Settled, InvoiceStatus.Invalid })
{
var inv = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 100 });
new CreateInvoiceRequest { Currency = "USD", Amount = 100 });
await user.PayInvoice(inv.Id);
await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest()
await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest
{
Status = marked
});
@ -2322,13 +2321,12 @@ namespace BTCPayServer.Tests
}
}
newInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest()
new CreateInvoiceRequest
{
Currency = "USD",
Amount = 1,
Checkout = new CreateInvoiceRequest.CheckoutOptions()
Checkout = new CreateInvoiceRequest.CheckoutOptions
{
DefaultLanguage = "it-it ",
RedirectURL = "http://toto.com/lol"

View file

@ -111,6 +111,7 @@ namespace BTCPayServer.Tests
// Payment Request
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
Thread.Sleep(10000);
s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123");
s.Driver.FindElement(By.Id("Amount")).SendKeys("700");
new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email");
@ -461,6 +462,12 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
s.CreateNewStore();
// Store Emails without server fallback
s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
// Server Emails
s.Driver.Navigate().GoToUrl(s.Link("/server/emails"));
@ -470,12 +477,11 @@ namespace BTCPayServer.Tests
s.FindAlertMessage();
}
CanSetupEmailCore(s);
s.CreateNewStore();
// Store Emails
// Store Emails with server fallback
s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
Assert.Contains("Emails will be sent with the email settings of the server", s.Driver.PageSource);
s.GoToStore(StoreNavPages.Emails);
CanSetupEmailCore(s);
@ -485,10 +491,11 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no rules yet.", s.Driver.PageSource);
Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource);
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
Assert.DoesNotContain("Emails will be sent with the email settings of the server", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateEmailRule")).Click();
var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger")));
select.SelectByText("InvoiceSettled", true);
select.SelectByText("An invoice has been settled", true);
s.Driver.FindElement(By.Id("Rules_0__To")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.Id("Rules_0__CustomerEmail")).Click();
s.Driver.FindElement(By.Id("Rules_0__Subject")).SendKeys("Thanks!");
@ -1418,13 +1425,6 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Name("update")).Click();
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("Modify")).Click();
foreach (var value in Enum.GetValues(typeof(WebhookEventType)))
{
// Here we make sure we did not forget an event type in the list
// However, maybe some event should not appear here because not at the store level.
// Fix as needed.
Assert.Contains($"value=\"{value}\"", s.Driver.PageSource);
}
// This one should be checked
Assert.Contains("value=\"InvoiceProcessing\" checked", s.Driver.PageSource);
@ -3052,13 +3052,18 @@ retry:
{
s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click();
s.Driver.FindElement(By.CssSelector("#quick-fill .dropdown-menu .dropdown-item:first-child")).Click();
s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit();
s.FindAlertMessage();
s.Driver.FindElement(By.Id("Settings_Password")).Clear();
s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword");
s.Driver.FindElement(By.Id("Settings_From")).Clear();
s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname <email@example.com>");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Configured", s.Driver.PageSource);
s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test_fix@gmail.com");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Configured", s.Driver.PageSource);

View file

@ -487,7 +487,7 @@ namespace BTCPayServer.Tests
}
public List<WebhookInvoiceEvent> WebhookEvents { get; set; } = new List<WebhookInvoiceEvent>();
public TEvent AssertHasWebhookEvent<TEvent>(WebhookEventType eventType, Action<TEvent> assert) where TEvent : class
public TEvent AssertHasWebhookEvent<TEvent>(string eventType, Action<TEvent> assert) where TEvent : class
{
int retry = 0;
retry:
@ -520,7 +520,7 @@ retry:
}
public async Task SetupWebhook()
{
FakeServer server = new FakeServer();
var server = new FakeServer();
await server.Start();
var client = await CreateClient(Policies.CanModifyStoreWebhooks);
var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest()
@ -536,7 +536,7 @@ retry:
{
var inv = await BitPay.GetInvoiceAsync(invoiceId);
var net = parent.ExplorerNode.Network;
this.parent.ExplorerNode.SendToAddress(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue);
await parent.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue);
await TestUtils.EventuallyAsync(async () =>
{
var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant);

View file

@ -1299,7 +1299,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var rng = new Random();
@ -1333,8 +1333,6 @@ namespace BTCPayServer.Tests
var btcmethod = (await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id))[0];
var paid = btcSent;
var invoiceAddress = BitcoinAddress.Create(btcmethod.Destination, cashCow.Network);
var btc = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
var networkFee = (await tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id))
.GetPaymentMethods()[btc]
@ -1346,9 +1344,7 @@ namespace BTCPayServer.Tests
networkFee = 0.0m;
}
cashCow.SendToAddress(invoiceAddress, paid);
await cashCow.SendToAddressAsync(invoiceAddress, paid);
await TestUtils.EventuallyAsync(async () =>
{
try
@ -1952,11 +1948,11 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetupWebhook();
var invoice = user.BitPay.CreateInvoice(
new Invoice()
var invoice = await user.BitPay.CreateInvoiceAsync(
new Invoice
{
Price = 5000.0m,
TaxIncluded = 1000.0m,
@ -2003,11 +1999,8 @@ namespace BTCPayServer.Tests
Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime - TimeSpan.FromDays(5),
invoice.InvoiceTime.DateTime - TimeSpan.FromDays(1)));
var firstPayment = Money.Coins(0.04m);
var txFee = Money.Zero;
var cashCow = tester.ExplorerNode;
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
@ -2035,7 +2028,7 @@ namespace BTCPayServer.Tests
secondPayment = localInvoice.BtcDue;
});
cashCow.SendToAddress(invoiceAddress, secondPayment);
await cashCow.SendToAddressAsync(invoiceAddress, secondPayment);
TestUtils.Eventually(() =>
{
@ -2049,7 +2042,7 @@ namespace BTCPayServer.Tests
Assert.False((bool)((JValue)localInvoice.ExceptionStatus).Value);
});
cashCow.Generate(1); //The user has medium speed settings, so 1 conf is enough to be confirmed
await cashCow.GenerateAsync(1); //The user has medium speed settings, so 1 conf is enough to be confirmed
TestUtils.Eventually(() =>
{
@ -2057,7 +2050,7 @@ namespace BTCPayServer.Tests
Assert.Equal("confirmed", localInvoice.Status);
});
cashCow.Generate(5); //Now should be complete
await cashCow.GenerateAsync(5); //Now should be complete
TestUtils.Eventually(() =>
{
@ -2066,7 +2059,7 @@ namespace BTCPayServer.Tests
Assert.NotEqual(0.0m, localInvoice.Rate);
});
invoice = user.BitPay.CreateInvoice(new Invoice()
invoice = await user.BitPay.CreateInvoiceAsync(new Invoice
{
Price = 5000.0m,
Currency = "USD",
@ -2079,7 +2072,7 @@ namespace BTCPayServer.Tests
}, Facade.Merchant);
invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
var txId = cashCow.SendToAddress(invoiceAddress, invoice.BtcDue + Money.Coins(1));
var txId = await cashCow.SendToAddressAsync(invoiceAddress, invoice.BtcDue + Money.Coins(1));
TestUtils.Eventually(() =>
{
@ -2096,7 +2089,7 @@ namespace BTCPayServer.Tests
Assert.Single(textSearchResult);
});
cashCow.Generate(1);
await cashCow.GenerateAsync(2);
TestUtils.Eventually(() =>
{

View file

@ -146,9 +146,7 @@ namespace BTCPayServer.Controllers.Greenfield
return PaymentRequestNotFound();
}
var updatedPr = pr.First();
updatedPr.Archived = true;
await _paymentRequestRepository.CreateOrUpdatePaymentRequest(updatedPr);
await _paymentRequestRepository.ArchivePaymentRequest(pr.First().Id);
return Ok();
}

View file

@ -10,6 +10,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Google.Apis.Auth.OAuth2;

View file

@ -12,6 +12,7 @@ using BTCPayServer.Services;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;

View file

@ -3,7 +3,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
@ -11,7 +10,6 @@ using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services;
@ -22,7 +20,6 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData;
@ -115,7 +112,8 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
var storeBlob = store.GetStoreBlob();
var prInvoices = payReqId is null ? null : (await _PaymentRequestService.GetPaymentRequest(payReqId, GetUserId())).Invoices;
var vm = new UpdatePaymentRequestViewModel(paymentRequest)
{
@ -123,7 +121,9 @@ namespace BTCPayServer.Controllers
AmountAndCurrencyEditable = payReqId is null || !prInvoices.Any()
};
vm.Currency ??= store.GetStoreBlob().DefaultCurrency;
vm.Currency ??= storeBlob.DefaultCurrency;
vm.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
return View(nameof(EditPaymentRequest), vm);
}
@ -162,10 +162,12 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
{
var storeBlob = store.GetStoreBlob();
viewModel.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
return View(nameof(EditPaymentRequest), viewModel);
}
blob.Title = viewModel.Title;
blob.Email = viewModel.Email;
blob.Description = viewModel.Description;
@ -413,13 +415,11 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> TogglePaymentRequestArchival(string payReqId)
{
var store = GetCurrentStore();
var result = await EditPaymentRequest(store.Id, payReqId);
if (result is ViewResult viewResult)
var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true);
if(result is not null)
{
var model = (UpdatePaymentRequestViewModel)viewResult.Model;
model.Archived = !model.Archived;
await EditPaymentRequest(payReqId, model);
TempData[WellKnownTempData.SuccessMessage] = model.Archived
TempData[WellKnownTempData.SuccessMessage] = result.Value
? "The payment request has been archived and will no longer appear in the payment request list by default again."
: "The payment request has been unarchived and will appear in the payment request list by default.";
return RedirectToAction("GetPaymentRequests", new { storeId = store.Id });

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -19,20 +20,25 @@ namespace BTCPayServer.Controllers
public partial class UIStoresController
{
[HttpGet("{storeId}/emails")]
public IActionResult StoreEmails(string storeId)
public async Task<IActionResult> StoreEmails(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var blob = store.GetStoreBlob();
var data = blob.EmailSettings;
if (data?.IsComplete() is not true)
var storeSetupComplete = blob.EmailSettings?.IsComplete() is true;
if (!storeSetupComplete && !TempData.HasStatusMessage())
{
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var hasServerFallback = await IsSetupComplete(emailSender?.FallbackSender);
var message = hasServerFallback
? "Emails will be sent with the email settings of the server"
: "You need to configure email settings before this feature works";
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = $"You need to configure email settings before this feature works. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure now</a>."
Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : StatusMessageModel.StatusSeverity.Warning,
Html = $"{message}. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
}
@ -44,17 +50,17 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command)
{
vm.Rules ??= new List<StoreEmailRule>();
int index = 0;
int commandIndex = 0;
var indSep = command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase);
if (indSep > 0)
{
var item = command[(indSep + 1)..];
index = int.Parse(item, CultureInfo.InvariantCulture);
commandIndex = int.Parse(item, CultureInfo.InvariantCulture);
}
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{
vm.Rules.RemoveAt(index);
vm.Rules.RemoveAt(commandIndex);
}
else if (command == "add")
{
@ -63,7 +69,7 @@ namespace BTCPayServer.Controllers
return View(vm);
}
for (var i = 0; index < vm.Rules.Count; index++)
for (var i = 0; i < vm.Rules.Count; i++)
{
var rule = vm.Rules[i];
if (!rule.CustomerEmail && string.IsNullOrEmpty(rule.To))
@ -79,41 +85,50 @@ namespace BTCPayServer.Controllers
if (store == null)
return NotFound();
string message = "";
// update rules
var blob = store.GetStoreBlob();
blob.EmailRules = vm.Rules;
if (store.SetStoreBlob(blob))
{
await _Repo.UpdateStore(store);
message += "Store email rules saved. ";
}
if (command.StartsWith("test", StringComparison.InvariantCultureIgnoreCase))
{
var rule = vm.Rules[index];
try
{
var emailSettings = blob.EmailSettings;
using var client = await emailSettings.CreateSmtpClient();
var message = emailSettings.CreateMailMessage(MailboxAddress.Parse(rule.To), "(test) " + rule.Subject, rule.Body, true);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Rule email saved and sent to {rule.To}. Please verify you received it.";
blob.EmailRules = vm.Rules;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
var rule = vm.Rules[commandIndex];
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id);
if (await IsSetupComplete(emailSender))
{
emailSender.SendEmail(MailboxAddress.Parse(rule.To), $"({store.StoreName} test) {rule.Subject}", rule.Body);
message += $"Test email sent to {rule.To} — please verify you received it.";
}
else
{
message += "Complete the email setup to send test emails.";
}
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = "Error: " + ex.Message;
TempData[WellKnownTempData.ErrorMessage] = message + "Error sending test email: " + ex.Message;
return RedirectToAction("StoreEmails", new { storeId });
}
}
else
if (!string.IsNullOrEmpty(message))
{
// UPDATE
blob.EmailRules = vm.Rules;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Store email rules saved"
Message = message
});
}
return RedirectToAction("StoreEmails", new { storeId });
}
@ -125,7 +140,7 @@ namespace BTCPayServer.Controllers
public class StoreEmailRule
{
[Required]
public WebhookEventType Trigger { get; set; }
public string Trigger { get; set; }
public bool CustomerEmail { get; set; }
@ -209,5 +224,10 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
}
private static async Task<bool> IsSetupComplete(IEmailSender emailSender)
{
return emailSender is not null && (await emailSender.GetEmailSettings())?.IsComplete() == true;
}
}
}

View file

@ -13,6 +13,7 @@ using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
@ -22,6 +23,7 @@ using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
@ -68,7 +70,8 @@ namespace BTCPayServer.Controllers
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions,
IHtmlHelper html,
LightningClientFactoryService lightningClientFactoryService)
LightningClientFactoryService lightningClientFactoryService,
EmailSenderFactory emailSenderFactory)
{
_RateFactory = rateFactory;
_Repo = repo;
@ -93,6 +96,7 @@ namespace BTCPayServer.Controllers
_BTCPayEnv = btcpayEnv;
_externalServiceOptions = externalServiceOptions;
_lightningClientFactoryService = lightningClientFactoryService;
_emailSenderFactory = emailSenderFactory;
Html = html;
}
@ -116,6 +120,7 @@ namespace BTCPayServer.Controllers
private readonly EventAggregator _EventAggregator;
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly EmailSenderFactory _emailSenderFactory;
public string? GeneratedPairingCode { get; set; }
public WebhookSender WebhookNotificationManager { get; }

View file

@ -37,6 +37,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly NotificationSender _notificationSender;
private readonly Logs Logs;
private readonly EventAggregator _eventAggregator;
private readonly TransactionLinkProviders _transactionLinkProviders;
public WalletRepository WalletRepository { get; }
@ -48,6 +49,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
ApplicationDbContextFactory dbContextFactory,
NotificationSender notificationSender,
Logs logs,
EventAggregator eventAggregator,
TransactionLinkProviders transactionLinkProviders)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
@ -57,6 +59,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
_dbContextFactory = dbContextFactory;
_notificationSender = notificationSender;
this.Logs = logs;
_eventAggregator = eventAggregator;
_transactionLinkProviders = transactionLinkProviders;
}
@ -329,7 +332,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
.Include(p => p.PullPaymentData)
.Where(p => p.State == PayoutState.InProgress)
.ToListAsync();
List<PayoutData> updatedPayouts = new List<PayoutData>();
foreach (var payout in payouts)
{
var proof = ParseProof(payout) as PayoutTransactionOnChainBlob;
@ -350,6 +353,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
{
payout.State = PayoutState.Completed;
proof.TransactionId = tx.TransactionHash;
updatedPayouts.Add(payout);
break;
}
else
@ -364,6 +368,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
{
payout.State = PayoutState.InProgress;
proof.TransactionId = tx.TransactionHash;
updatedPayouts.Add(payout);
continue;
}
}
@ -376,6 +381,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
if (proof.Candidates.Count == 0)
{
if (payout.State != PayoutState.AwaitingPayment)
{
updatedPayouts.Add(payout);
}
payout.State = PayoutState.AwaitingPayment;
}
else if (proof.TransactionId is null)
@ -389,6 +398,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
}
await ctx.SaveChangesAsync();
foreach (PayoutData payoutData in updatedPayouts)
{
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated,payoutData));
}
}
catch (Exception ex)
{
@ -466,9 +479,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
proof.TransactionId ??= txId;
SetProofBlob(payout, proof);
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated,payout));
}
catch (Exception ex)
{

View file

@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
@ -35,6 +36,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly IOptions<LightningNetworkOptions> _options;
private readonly IAuthorizationService _authorizationService;
private readonly EventAggregator _eventAggregator;
private readonly StoreRepository _storeRepository;
public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory,
@ -44,7 +46,9 @@ namespace BTCPayServer.Data.Payouts.LightningLike
BTCPayNetworkProvider btcPayNetworkProvider,
StoreRepository storeRepository,
LightningClientFactoryService lightningClientFactoryService,
IOptions<LightningNetworkOptions> options, IAuthorizationService authorizationService)
IOptions<LightningNetworkOptions> options,
IAuthorizationService authorizationService,
EventAggregator eventAggregator)
{
_applicationDbContextFactory = applicationDbContextFactory;
_userManager = userManager;
@ -55,6 +59,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
_options = options;
_storeRepository = storeRepository;
_authorizationService = authorizationService;
_eventAggregator = eventAggregator;
}
private async Task<List<PayoutData>> GetPayouts(ApplicationDbContext dbContext, PaymentMethodId pmi,
@ -214,6 +219,16 @@ namespace BTCPayServer.Data.Payouts.LightningLike
}
await ctx.SaveChangesAsync();
foreach (var payoutG in payouts)
{
foreach (PayoutData payout in payoutG)
{
if (payout.State != PayoutState.AwaitingPayment)
{
_eventAggregator.Publish(new PayoutEvent(null, payout));
}
}
}
return View("LightningPayoutResult", results);
}
public static async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData,

View file

@ -5,10 +5,10 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices.Webhooks;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SshNet.Security.Cryptography;
namespace BTCPayServer.Data
{
@ -16,9 +16,8 @@ namespace BTCPayServer.Data
{
public bool Everything { get; set; }
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>();
public bool Match(WebhookEventType evt)
public string[] SpecificEvents { get; set; } = Array.Empty<string>();
public bool Match(string evt)
{
return Everything || SpecificEvents.Contains(evt);
}
@ -47,7 +46,7 @@ namespace BTCPayServer.Data
}
public T ReadRequestAs<T>()
{
return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings);
return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), WebhookSender.DefaultSerializerSettings);
}
public bool IsPruned()
@ -78,14 +77,14 @@ namespace BTCPayServer.Data
if (webhook.Blob is null)
return null;
else
return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, HostedServices.WebhookSender.DefaultSerializerSettings);
return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, WebhookSender.DefaultSerializerSettings);
}
public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
{
if (blob is null)
webhook.Blob = null;
else
webhook.Blob = JsonConvert.SerializeObject(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
webhook.Blob = JsonConvert.SerializeObject(blob, WebhookSender.DefaultSerializerSettings);
}
}
}

View file

@ -22,17 +22,8 @@ namespace BTCPayServer.HostedServices
{
private const string TYPE = "pluginupdate";
internal class Handler : NotificationHandler<PluginUpdateNotification>
internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options) : NotificationHandler<PluginUpdateNotification>
{
private readonly LinkGenerator _linkGenerator;
private readonly BTCPayServerOptions _options;
public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
{
_linkGenerator = linkGenerator;
_options = options;
}
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
@ -48,9 +39,9 @@ namespace BTCPayServer.HostedServices
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body = $"New {notification.Name} plugin version {notification.Version} released!";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins),
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins),
"UIServer",
new {plugin = notification.PluginIdentifier}, _options.RootPath);
new {plugin = notification.PluginIdentifier}, options.RootPath);
}
}
@ -79,34 +70,19 @@ namespace BTCPayServer.HostedServices
public Dictionary<string, Version> LastVersions { get; set; }
}
public class PluginUpdateFetcher : IPeriodicTask
public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService)
: IPeriodicTask
{
public PluginUpdateFetcher(
SettingsRepository settingsRepository,
ILogger<PluginUpdateFetcher> logger, NotificationSender notificationSender, PluginService pluginService)
{
_settingsRepository = settingsRepository;
_logger = logger;
_notificationSender = notificationSender;
_pluginService = pluginService;
}
private readonly SettingsRepository _settingsRepository;
private readonly ILogger<PluginUpdateFetcher> _logger;
private readonly NotificationSender _notificationSender;
private readonly PluginService _pluginService;
public async Task Do(CancellationToken cancellationToken)
{
var dh = await _settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
var dh = await settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder();
dh.LastVersions ??= new Dictionary<string, Version>();
var disabledPlugins = _pluginService.GetDisabledPlugins();
var disabledPlugins = pluginService.GetDisabledPlugins();
var installedPlugins =
_pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var remotePlugins = await _pluginService.GetRemotePlugins();
pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var remotePlugins = await pluginService.GetRemotePlugins();
var remotePluginsList = remotePlugins
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name))
.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
@ -128,10 +104,10 @@ namespace BTCPayServer.HostedServices
foreach (string pluginUpdate in notify)
{
var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate);
await _notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin));
await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin));
}
await _settingsRepository.UpdateSetting(dh);
await settingsRepository.UpdateSetting(dh);
}
}
}

View file

@ -493,6 +493,7 @@ namespace BTCPayServer.HostedServices
}
payout.State = req.Request.State;
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(null, payout));
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok);
}
catch (Exception ex)
@ -711,6 +712,11 @@ namespace BTCPayServer.HostedServices
}
await ctx.SaveChangesAsync();
foreach (var keyValuePair in result.Where(pair => pair.Value == MarkPayoutRequest.PayoutPaidResult.Ok))
{
var payout = payouts.First(p => p.Id == keyValuePair.Key);
_eventAggregator.Publish(new PayoutEvent(null, payout));
}
cancel.Completion.TrySetResult(result);
}
catch (Exception ex)
@ -929,13 +935,13 @@ namespace BTCPayServer.HostedServices
public JObject Metadata { get; set; }
}
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout)
public record PayoutEvent(PayoutEvent.PayoutEventType? Type, PayoutData Payout)
{
public enum PayoutEventType
{
Created,
Approved
Approved,
Updated
}
}
}

View file

@ -1,20 +1,14 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
namespace BTCPayServer.HostedServices;
@ -22,82 +16,75 @@ public class StoreEmailRuleProcessorSender : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly LinkGenerator _linkGenerator;
private readonly CurrencyNameTable _currencyNameTable;
public StoreEmailRuleProcessorSender(StoreRepository storeRepository, EventAggregator eventAggregator,
ILogger<InvoiceEventSaverService> logger,
EmailSenderFactory emailSenderFactory,
LinkGenerator linkGenerator,
CurrencyNameTable currencyNameTable) : base(
EmailSenderFactory emailSenderFactory) : base(
eventAggregator, logger)
{
_storeRepository = storeRepository;
_emailSenderFactory = emailSenderFactory;
_linkGenerator = linkGenerator;
_currencyNameTable = currencyNameTable;
}
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<WebhookSender.WebhookDeliveryRequest>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent)
if (evt is WebhookSender.WebhookDeliveryRequest webhookDeliveryRequest)
{
var type = WebhookSender.GetWebhookEvent(invoiceEvent);
var type = webhookDeliveryRequest.WebhookEvent.Type;
if (type is null)
{
return;
}
var store = await _storeRepository.FindStore(invoiceEvent.Invoice.StoreId);
if (webhookDeliveryRequest.WebhookEvent is not StoreWebhookEvent storeWebhookEvent || storeWebhookEvent.StoreId is null)
{
return;
}
var store = await _storeRepository.FindStore(storeWebhookEvent.StoreId);
if (store is null)
{
return;
}
var blob = store.GetStoreBlob();
if (blob.EmailRules?.Any() is true)
{
var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type.Type).ToList();
var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type).ToList();
if (actionableRules.Any())
{
var sender = await _emailSenderFactory.GetEmailSender(invoiceEvent.Invoice.StoreId);
var sender = await _emailSenderFactory.GetEmailSender(storeWebhookEvent.StoreId);
foreach (UIStoresController.StoreEmailRule actionableRule in actionableRules)
{
var recipients = (actionableRule.To?.Split(",", StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
var request = new SendEmailRequest()
{
Subject = actionableRule.Subject, Body = actionableRule.Body, Email = actionableRule.To
};
request = await webhookDeliveryRequest.Interpolate(request, actionableRule);
var recipients = (request?.Email?.Split(",", StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.Select(o =>
{
MailboxAddressValidator.TryParse(o, out var mb);
return mb;
})
.Where(o => o != null)
.ToList();
if (actionableRule.CustomerEmail &&
MailboxAddressValidator.TryParse(invoiceEvent.Invoice.Metadata.BuyerEmail, out var bmb))
{
recipients.Add(bmb);
}
var i = GreenfieldInvoiceController.ToModel(invoiceEvent.Invoice, _linkGenerator, null);
sender.SendEmail(recipients.ToArray(), null, null, Interpolator(actionableRule.Subject, i),
Interpolator(actionableRule.Body, i));
.ToArray();
if(recipients.Length == 0)
continue;
sender.SendEmail(recipients.ToArray(), null, null, request.Subject, request.Body);
}
}
}
}
}
private string Interpolator(string str, InvoiceData i)
{
//TODO: we should switch to https://dotnetfiddle.net/MoqJFk later
return str.Replace("{Invoice.Id}", i.Id)
.Replace("{Invoice.StoreId}", i.StoreId)
.Replace("{Invoice.Price}",
decimal.Round(i.Amount, _currencyNameTable.GetCurrencyData(i.Currency, true).Divisibility,
MidpointRounding.ToEven).ToString(CultureInfo.InvariantCulture))
.Replace("{Invoice.Currency}", i.Currency)
.Replace("{Invoice.Status}", i.Status.ToString())
.Replace("{Invoice.AdditionalStatus}", i.AdditionalStatus.ToString())
.Replace("{Invoice.OrderId}", i.Metadata.ToObject<InvoiceMetadata>().OrderId);
}
}

View file

@ -1,390 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
namespace BTCPayServer.HostedServices
{
/// <summary>
/// This class send webhook notifications
/// It also make sure the events sent to a webhook are sent in order to the webhook
/// </summary>
public class WebhookSender : EventHostedServiceBase
{
readonly Encoding UTF8 = new UTF8Encoding(false);
public readonly static JsonSerializerSettings DefaultSerializerSettings;
static WebhookSender()
{
DefaultSerializerSettings = WebhookEvent.DefaultSerializerSettings;
}
public const string OnionNamedClient = "greenfield-webhook.onion";
public const string ClearnetNamedClient = "greenfield-webhook.clearnet";
public const string LoopbackNamedClient = "greenfield-webhook.loopback";
public static string[] AllClients = new[] { OnionNamedClient, ClearnetNamedClient, LoopbackNamedClient };
private HttpClient GetClient(Uri uri)
{
return HttpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient : uri.IsLoopback ? LoopbackNamedClient : ClearnetNamedClient);
}
class WebhookDeliveryRequest
{
public WebhookEvent WebhookEvent;
public Data.WebhookDeliveryData Delivery;
public WebhookBlob WebhookBlob;
public string WebhookId;
public WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent, Data.WebhookDeliveryData delivery, WebhookBlob webhookBlob)
{
WebhookId = webhookId;
WebhookEvent = webhookEvent;
Delivery = delivery;
WebhookBlob = webhookBlob;
}
}
MultiProcessingQueue _processingQueue = new MultiProcessingQueue();
public StoreRepository StoreRepository { get; }
public IHttpClientFactory HttpClientFactory { get; }
public WebhookSender(EventAggregator eventAggregator,
StoreRepository storeRepository,
IHttpClientFactory httpClientFactory,
Logs logs) : base(eventAggregator, logs)
{
StoreRepository = storeRepository;
HttpClientFactory = httpClientFactory;
}
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
}
public async Task<string?> Redeliver(string deliveryId)
{
var deliveryRequest = await CreateRedeliveryRequest(deliveryId);
if (deliveryRequest is null)
return null;
EnqueueDelivery(deliveryRequest);
return deliveryRequest.Delivery.Id;
}
private async Task<WebhookDeliveryRequest?> CreateRedeliveryRequest(string deliveryId)
{
using var ctx = StoreRepository.CreateDbContext();
var webhookDelivery = await ctx.WebhookDeliveries.AsNoTracking()
.Where(o => o.Id == deliveryId)
.Select(o => new
{
Webhook = o.Webhook,
Delivery = o
})
.FirstOrDefaultAsync();
if (webhookDelivery is null)
return null;
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
var newDelivery = NewDelivery(webhookDelivery.Webhook.Id);
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
webhookEvent.OriginalDeliveryId ??= deliveryId;
webhookEvent.IsRedelivery = true;
newDeliveryBlob.Request = ToBytes(webhookEvent);
newDelivery.SetBlob(newDeliveryBlob);
return new WebhookDeliveryRequest(webhookDelivery.Webhook.Id, webhookEvent, newDelivery, webhookDelivery.Webhook.GetBlob());
}
private WebhookEvent GetTestWebHook(string storeId, string webhookId, WebhookEventType webhookEventType, Data.WebhookDeliveryData delivery)
{
var webhookEvent = GetWebhookEvent(webhookEventType);
webhookEvent.InvoiceId = "__test__" + Guid.NewGuid().ToString() + "__test__";
webhookEvent.StoreId = storeId;
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.WebhookId = webhookId;
webhookEvent.OriginalDeliveryId = "__test__" + Guid.NewGuid().ToString() + "__test__";
webhookEvent.IsRedelivery = false;
webhookEvent.Timestamp = delivery.Timestamp;
return webhookEvent;
}
public async Task<DeliveryResult> TestWebhook(string storeId, string webhookId, WebhookEventType webhookEventType, CancellationToken cancellationToken)
{
var delivery = NewDelivery(webhookId);
var webhook = (await StoreRepository.GetWebhooks(storeId)).FirstOrDefault(w => w.Id == webhookId);
var deliveryRequest = new WebhookDeliveryRequest(
webhookId,
GetTestWebHook(storeId, webhookId, webhookEventType, delivery),
delivery,
webhook.GetBlob()
);
return await SendDelivery(deliveryRequest, cancellationToken);
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent)
{
var webhooks = await StoreRepository.GetWebhooks(invoiceEvent.Invoice.StoreId);
foreach (var webhook in webhooks)
{
var webhookBlob = webhook.GetBlob();
if (!(GetWebhookEvent(invoiceEvent) is WebhookInvoiceEvent webhookEvent))
continue;
if (!ShouldDeliver(webhookEvent.Type, webhookBlob))
continue;
Data.WebhookDeliveryData delivery = NewDelivery(webhook.Id);
webhookEvent.InvoiceId = invoiceEvent.InvoiceId;
webhookEvent.StoreId = invoiceEvent.Invoice.StoreId;
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.WebhookId = webhook.Id;
webhookEvent.OriginalDeliveryId = delivery.Id;
webhookEvent.Metadata = invoiceEvent.Invoice.Metadata.ToJObject();
webhookEvent.IsRedelivery = false;
webhookEvent.Timestamp = delivery.Timestamp;
var context = new WebhookDeliveryRequest(webhook.Id, webhookEvent, delivery, webhookBlob);
EnqueueDelivery(context);
}
}
}
private void EnqueueDelivery(WebhookDeliveryRequest context)
{
_processingQueue.Enqueue(context.WebhookId, (cancellationToken) => Process(context, cancellationToken));
}
public static WebhookInvoiceEvent GetWebhookEvent(WebhookEventType webhookEventType)
{
switch (webhookEventType)
{
case WebhookEventType.InvoiceCreated:
return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated);
case WebhookEventType.InvoiceReceivedPayment:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoiceReceivedPayment);
case WebhookEventType.InvoicePaymentSettled:
return new WebhookInvoicePaymentSettledEvent(WebhookEventType.InvoicePaymentSettled);
case WebhookEventType.InvoiceProcessing:
return new WebhookInvoiceProcessingEvent(WebhookEventType.InvoiceProcessing);
case WebhookEventType.InvoiceExpired:
return new WebhookInvoiceExpiredEvent(WebhookEventType.InvoiceExpired);
case WebhookEventType.InvoiceSettled:
return new WebhookInvoiceSettledEvent(WebhookEventType.InvoiceSettled);
case WebhookEventType.InvoiceInvalid:
return new WebhookInvoiceInvalidEvent(WebhookEventType.InvoiceInvalid);
default:
return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated);
}
}
public static WebhookInvoiceEvent? GetWebhookEvent(InvoiceEvent invoiceEvent)
{
var eventCode = invoiceEvent.EventCode;
switch (eventCode)
{
case InvoiceEventCode.Completed:
case InvoiceEventCode.PaidAfterExpiration:
return null;
case InvoiceEventCode.Confirmed:
case InvoiceEventCode.MarkedCompleted:
return new WebhookInvoiceSettledEvent(WebhookEventType.InvoiceSettled)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedCompleted
};
case InvoiceEventCode.Created:
return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated);
case InvoiceEventCode.Expired:
return new WebhookInvoiceExpiredEvent(WebhookEventType.InvoiceExpired)
{
PartiallyPaid = invoiceEvent.PaidPartial
};
case InvoiceEventCode.FailedToConfirm:
case InvoiceEventCode.MarkedInvalid:
return new WebhookInvoiceInvalidEvent(WebhookEventType.InvoiceInvalid)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedInvalid
};
case InvoiceEventCode.PaidInFull:
return new WebhookInvoiceProcessingEvent(WebhookEventType.InvoiceProcessing)
{
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver,
};
case InvoiceEventCode.ReceivedPayment:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoiceReceivedPayment)
{
AfterExpiration = invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Expired || invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Invalid,
PaymentMethod = invoiceEvent.Payment.GetPaymentMethodId().ToStringNormalized(),
Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment)
};
case InvoiceEventCode.PaymentSettled:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoicePaymentSettled)
{
AfterExpiration = invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Expired || invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Invalid,
PaymentMethod = invoiceEvent.Payment.GetPaymentMethodId().ToStringNormalized(),
Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver,
};
default:
return null;
}
}
private async Task Process(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
{
try
{
var wh = (await StoreRepository.GetWebhook(ctx.WebhookId))?.GetBlob();
if (wh is null || !ShouldDeliver(ctx.WebhookEvent.Type, wh))
return;
var result = await SendAndSaveDelivery(ctx, cancellationToken);
if (ctx.WebhookBlob.AutomaticRedelivery &&
!result.Success &&
result.DeliveryId is not null)
{
var originalDeliveryId = result.DeliveryId;
foreach (var wait in new[]
{
TimeSpan.FromSeconds(10),
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10),
})
{
await Task.Delay(wait, cancellationToken);
ctx = (await CreateRedeliveryRequest(originalDeliveryId))!;
// This may have changed
if (ctx is null || !ctx.WebhookBlob.AutomaticRedelivery ||
!ShouldDeliver(ctx.WebhookEvent.Type, ctx.WebhookBlob))
return;
result = await SendAndSaveDelivery(ctx, cancellationToken);
if (result.Success)
return;
}
}
}
catch when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex, "Unexpected error when processing a webhook");
}
}
private static bool ShouldDeliver(WebhookEventType type, WebhookBlob wh)
{
return wh.Active && wh.AuthorizedEvents.Match(type);
}
public class DeliveryResult
{
public string? DeliveryId { get; set; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
private async Task<DeliveryResult> SendDelivery(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
{
var uri = new Uri(ctx.WebhookBlob.Url, UriKind.Absolute);
var httpClient = GetClient(uri);
using var request = new HttpRequestMessage();
request.RequestUri = uri;
request.Method = HttpMethod.Post;
byte[] bytes = ToBytes(ctx.WebhookEvent);
var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var hmac = new System.Security.Cryptography.HMACSHA256(UTF8.GetBytes(ctx.WebhookBlob.Secret ?? string.Empty));
var sig = Encoders.Hex.EncodeData(hmac.ComputeHash(bytes));
content.Headers.Add("BTCPay-Sig", $"sha256={sig}");
request.Content = content;
var deliveryBlob = ctx.Delivery.GetBlob() ?? new WebhookDeliveryBlob();
deliveryBlob.Request = bytes;
try
{
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
deliveryBlob.Status = WebhookDeliveryStatus.HttpError;
deliveryBlob.ErrorMessage = $"HTTP Error Code {(int)response.StatusCode}";
}
else
{
deliveryBlob.Status = WebhookDeliveryStatus.HttpSuccess;
}
deliveryBlob.HttpCode = (int)response.StatusCode;
}
catch (Exception ex) when (!CancellationToken.IsCancellationRequested)
{
deliveryBlob.Status = WebhookDeliveryStatus.Failed;
deliveryBlob.ErrorMessage = ex.Message;
}
ctx.Delivery.SetBlob(deliveryBlob);
return new DeliveryResult()
{
Success = deliveryBlob.ErrorMessage is null,
DeliveryId = ctx.Delivery.Id,
ErrorMessage = deliveryBlob.ErrorMessage
};
}
private async Task<DeliveryResult> SendAndSaveDelivery(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
{
var result = await SendDelivery(ctx, cancellationToken);
await StoreRepository.AddWebhookDelivery(ctx.Delivery);
return result;
}
private byte[] ToBytes(WebhookEvent webhookEvent)
{
var str = JsonConvert.SerializeObject(webhookEvent, Formatting.Indented, DefaultSerializerSettings);
var bytes = UTF8.GetBytes(str);
return bytes;
}
private static Data.WebhookDeliveryData NewDelivery(string webhookId)
{
return new Data.WebhookDeliveryData
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)),
Timestamp = DateTimeOffset.UtcNow,
WebhookId = webhookId
};
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
var stopping = _processingQueue.Abort(cancellationToken);
await base.StopAsync(cancellationToken);
await stopping;
}
}
}

View file

@ -0,0 +1,12 @@
#nullable enable
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.HostedServices.Webhooks;
public interface IWebhookProvider
{
public Dictionary<string,string> GetSupportedWebhookTypes();
public WebhookEvent CreateTestEvent(string type, params object[] args);
}

View file

@ -0,0 +1,53 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json.Linq;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
public class InvoiceWebhookDeliveryRequest : WebhookSender.WebhookDeliveryRequest
{
public InvoiceEntity Invoice { get; }
public InvoiceWebhookDeliveryRequest(InvoiceEntity invoice, string webhookId, WebhookEvent webhookEvent,
WebhookDeliveryData delivery, WebhookBlob webhookBlob) : base(webhookId, webhookEvent, delivery, webhookBlob)
{
Invoice = invoice;
}
public override Task<SendEmailRequest> Interpolate(SendEmailRequest req,
UIStoresController.StoreEmailRule storeEmailRule)
{
if (storeEmailRule.CustomerEmail &&
MailboxAddressValidator.TryParse(Invoice.Metadata.BuyerEmail, out var bmb))
{
req.Email ??= string.Empty;
req.Email += $",{bmb}";
}
req.Subject = Interpolate(req.Subject);
req.Body = Interpolate(req.Body);
return Task.FromResult(req);
}
private string Interpolate(string str)
{
var res = str.Replace("{Invoice.Id}", Invoice.Id)
.Replace("{Invoice.StoreId}", Invoice.StoreId)
.Replace("{Invoice.Price}", Invoice.Price.ToString(CultureInfo.InvariantCulture))
.Replace("{Invoice.Currency}", Invoice.Currency)
.Replace("{Invoice.Status}", Invoice.Status.ToString())
.Replace("{Invoice.AdditionalStatus}", Invoice.ExceptionStatus.ToString())
.Replace("{Invoice.OrderId}", Invoice.Metadata.OrderId);
res = InterpolateJsonField(str, "Invoice.Metadata", Invoice.Metadata.ToJObject());
return res;
}
}

View file

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Logging;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
public class InvoiceWebhookProvider : WebhookProvider<InvoiceEvent>
{
public InvoiceWebhookProvider(WebhookSender webhookSender, EventAggregator eventAggregator,
ILogger<InvoiceWebhookProvider> logger) : base(
eventAggregator, logger, webhookSender)
{
}
public override Dictionary<string, string> GetSupportedWebhookTypes()
{
return new Dictionary<string, string>
{
{WebhookEventType.InvoiceCreated, "A new invoice has been created"},
{WebhookEventType.InvoiceReceivedPayment, "A new payment has been received"},
{WebhookEventType.InvoicePaymentSettled, "A payment has been settled"},
{WebhookEventType.InvoiceProcessing, "An invoice is processing"},
{WebhookEventType.InvoiceExpired, "An invoice has expired"},
{WebhookEventType.InvoiceSettled, "An invoice has been settled"},
{WebhookEventType.InvoiceInvalid, "An invoice became invalid"},
};
}
protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(InvoiceEvent invoiceEvent,
WebhookData webhook)
{
var webhookEvent = GetWebhookEvent(invoiceEvent)!;
var webhookBlob = webhook?.GetBlob();
webhookEvent.InvoiceId = invoiceEvent.InvoiceId;
webhookEvent.StoreId = invoiceEvent.Invoice.StoreId;
webhookEvent.Metadata = invoiceEvent.Invoice.Metadata.ToJObject();
webhookEvent.WebhookId = webhook?.Id;
webhookEvent.IsRedelivery = false;
WebhookDeliveryData delivery = webhook is null? null: WebhookExtensions.NewWebhookDelivery(webhook.Id);
if (delivery is not null)
{
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.OriginalDeliveryId = delivery.Id;
webhookEvent.Timestamp = delivery.Timestamp;
}
return new InvoiceWebhookDeliveryRequest(invoiceEvent.Invoice, webhook?.Id, webhookEvent,
delivery, webhookBlob);
}
public override WebhookEvent CreateTestEvent(string type, params object[] args)
{
var storeId = args[0].ToString();
return new WebhookInvoiceEvent(type, storeId)
{
InvoiceId = "__test__" + Guid.NewGuid() + "__test__"
};
}
protected override WebhookInvoiceEvent GetWebhookEvent(InvoiceEvent invoiceEvent)
{
var eventCode = invoiceEvent.EventCode;
var storeId = invoiceEvent.Invoice.StoreId;
switch (eventCode)
{
case InvoiceEventCode.Confirmed:
case InvoiceEventCode.MarkedCompleted:
return new WebhookInvoiceSettledEvent(storeId)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedCompleted
};
case InvoiceEventCode.Created:
return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated, storeId);
case InvoiceEventCode.Expired:
return new WebhookInvoiceExpiredEvent(storeId)
{
PartiallyPaid = invoiceEvent.PaidPartial
};
case InvoiceEventCode.FailedToConfirm:
case InvoiceEventCode.MarkedInvalid:
return new WebhookInvoiceInvalidEvent(storeId)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedInvalid
};
case InvoiceEventCode.PaidInFull:
return new WebhookInvoiceProcessingEvent(storeId)
{
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver
};
case InvoiceEventCode.ReceivedPayment:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoiceReceivedPayment, storeId)
{
AfterExpiration =
invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Expired ||
invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Invalid,
PaymentMethod = invoiceEvent.Payment.GetPaymentMethodId().ToStringNormalized(),
Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
StoreId = invoiceEvent.Invoice.StoreId
};
case InvoiceEventCode.PaymentSettled:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoicePaymentSettled, storeId)
{
AfterExpiration =
invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Expired ||
invoiceEvent.Invoice.Status.ToModernStatus() == InvoiceStatus.Invalid,
PaymentMethod = invoiceEvent.Payment.GetPaymentMethodId().ToStringNormalized(),
Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver,
StoreId = invoiceEvent.Invoice.StoreId
};
default:
return null;
}
}
}

View file

@ -0,0 +1,50 @@
#nullable enable
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services.PaymentRequests;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
public class PaymentRequestWebhookDeliveryRequest : WebhookSender.WebhookDeliveryRequest
{
private readonly PaymentRequestEvent _evt;
public PaymentRequestWebhookDeliveryRequest(PaymentRequestEvent evt, string webhookId, WebhookEvent webhookEvent,
WebhookDeliveryData delivery, WebhookBlob webhookBlob) : base(webhookId, webhookEvent, delivery, webhookBlob)
{
_evt = evt;
}
public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
UIStoresController.StoreEmailRule storeEmailRule)
{
var blob = _evt.Data.GetBlob();
if (storeEmailRule.CustomerEmail &&
MailboxAddressValidator.TryParse(blob.Email, out var bmb))
{
req.Email ??= string.Empty;
req.Email += $",{bmb}";
}
req.Subject = Interpolate(req.Subject, blob);
req.Body = Interpolate(req.Body, blob);
return Task.FromResult(req)!;
}
private string Interpolate(string str, PaymentRequestBaseData blob)
{
var res= str.Replace("{PaymentRequest.Id}", _evt.Data.Id)
.Replace("{PaymentRequest.Price}", blob.Amount.ToString(CultureInfo.InvariantCulture))
.Replace("{PaymentRequest.Currency}", blob.Currency)
.Replace("{PaymentRequest.Title}", blob.Title)
.Replace("{PaymentRequest.Description}", blob.Description)
.Replace("{PaymentRequest.Status}", _evt.Data.Status.ToString());
res= InterpolateJsonField(res, "PaymentRequest.FormResponse", blob.FormResponse);
return res;
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.Extensions.Logging;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
public class PaymentRequestWebhookProvider: WebhookProvider<PaymentRequestEvent>
{
public PaymentRequestWebhookProvider(EventAggregator eventAggregator, ILogger<PaymentRequestWebhookProvider> logger, WebhookSender webhookSender) : base(eventAggregator, logger, webhookSender)
{
}
public override Dictionary<string, string> GetSupportedWebhookTypes()
{
return new Dictionary<string, string>()
{
{WebhookEventType.PaymentRequestCreated, "Payment Request Created"},
{WebhookEventType.PaymentRequestUpdated, "Payment Request Updated"},
{WebhookEventType.PaymentRequestArchived, "Payment Request Archived"},
{WebhookEventType.PaymentRequestStatusChanged, "Payment Request Status Changed"},
};
}
public override WebhookEvent CreateTestEvent(string type, object[] args)
{
var storeId = args[0].ToString();
return new WebhookPayoutEvent(type, storeId)
{
PayoutId = "__test__" + Guid.NewGuid() + "__test__"
};
}
protected override WebhookPaymentRequestEvent GetWebhookEvent(PaymentRequestEvent evt)
{
return evt.Type switch
{
PaymentRequestEvent.Created => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestCreated, evt.Data.StoreDataId),
PaymentRequestEvent.Updated => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestUpdated, evt.Data.StoreDataId),
PaymentRequestEvent.Archived => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestArchived, evt.Data.StoreDataId),
PaymentRequestEvent.StatusChanged => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestStatusChanged, evt.Data.StoreDataId),
_ => null
};
}
protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(PaymentRequestEvent paymentRequestEvent, WebhookData webhook)
{
var webhookBlob = webhook?.GetBlob();
var webhookEvent = GetWebhookEvent(paymentRequestEvent)!;
webhookEvent.StoreId = paymentRequestEvent.Data.StoreDataId;
webhookEvent.PaymentRequestId = paymentRequestEvent.Data.Id;
webhookEvent.Status = paymentRequestEvent.Data.Status;
webhookEvent.WebhookId = webhook?.Id;
webhookEvent.IsRedelivery = false;
WebhookDeliveryData delivery = webhook is null? null: WebhookExtensions.NewWebhookDelivery(webhook.Id);
if (delivery is not null)
{
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.OriginalDeliveryId = delivery.Id;
webhookEvent.Timestamp = delivery.Timestamp;
}
return new PaymentRequestWebhookDeliveryRequest(paymentRequestEvent,webhook?.Id, webhookEvent, delivery, webhookBlob );
}
}

View file

@ -0,0 +1,36 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
public class PayoutWebhookDeliveryRequest(PayoutEvent evt, string? webhookId, WebhookEvent webhookEvent,
WebhookDeliveryData? delivery, WebhookBlob? webhookBlob,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
: WebhookSender.WebhookDeliveryRequest(webhookId!, webhookEvent, delivery!, webhookBlob!)
{
public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
UIStoresController.StoreEmailRule storeEmailRule)
{
req.Subject = Interpolate(req.Subject);
req.Body = Interpolate(req.Body);
return Task.FromResult(req)!;
}
private string Interpolate(string str)
{
var res= str.Replace("{Payout.Id}", evt.Payout.Id)
.Replace("{Payout.PullPaymentId}", evt.Payout.PullPaymentDataId)
.Replace("{Payout.Destination}", evt.Payout.Destination)
.Replace("{Payout.State}", evt.Payout.State.ToString());
var blob = evt.Payout.GetBlob(btcPayNetworkJsonSerializerSettings);
res = InterpolateJsonField(res, "Payout.Metadata", blob.Metadata);
return res;
}
}

View file

@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.HostedServices.Webhooks;
public class PayoutWebhookProvider(EventAggregator eventAggregator, ILogger<PayoutWebhookProvider> logger,
WebhookSender webhookSender, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
: WebhookProvider<PayoutEvent>(eventAggregator, logger, webhookSender)
{
protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(PayoutEvent payoutEvent, WebhookData webhook)
{
var webhookBlob = webhook?.GetBlob();
var webhookEvent = GetWebhookEvent(payoutEvent)!;
webhookEvent.StoreId = payoutEvent.Payout.StoreDataId;
webhookEvent.PayoutId = payoutEvent.Payout.Id;
webhookEvent.PayoutState = payoutEvent.Payout.State;
webhookEvent.PullPaymentId = payoutEvent.Payout.PullPaymentDataId;
webhookEvent.WebhookId = webhook?.Id;
webhookEvent.IsRedelivery = false;
Data.WebhookDeliveryData delivery = webhook is null? null: WebhookExtensions.NewWebhookDelivery(webhook.Id);
if (delivery is not null)
{
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.OriginalDeliveryId = delivery.Id;
webhookEvent.Timestamp = delivery.Timestamp;
}
return new PayoutWebhookDeliveryRequest(payoutEvent,webhook?.Id, webhookEvent, delivery, webhookBlob, btcPayNetworkJsonSerializerSettings);
}
public override Dictionary<string, string> GetSupportedWebhookTypes()
{
return new Dictionary<string, string>()
{
{WebhookEventType.PayoutCreated, "A payout has been created"},
{WebhookEventType.PayoutApproved, "A payout has been approved"},
{WebhookEventType.PayoutUpdated, "A payout was updated"}
};
}
public override WebhookEvent CreateTestEvent(string type, object[] args)
{
var storeId = args[0].ToString();
return new WebhookPayoutEvent(type, storeId)
{
PayoutId = "__test__" + Guid.NewGuid() + "__test__"
};
}
protected override WebhookPayoutEvent GetWebhookEvent(PayoutEvent payoutEvent)
{
return payoutEvent.Type switch
{
PayoutEvent.PayoutEventType.Created => new WebhookPayoutEvent(WebhookEventType.PayoutCreated, payoutEvent.Payout.StoreDataId),
PayoutEvent.PayoutEventType.Approved => new WebhookPayoutEvent(WebhookEventType.PayoutApproved, payoutEvent.Payout.StoreDataId),
PayoutEvent.PayoutEventType.Updated => new WebhookPayoutEvent(WebhookEventType.PayoutUpdated, payoutEvent.Payout.StoreDataId),
_ => null
};
}
}

View file

@ -0,0 +1,56 @@
using System;
using System.Net.Http;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitcoin.DataEncoders;
namespace BTCPayServer.HostedServices.Webhooks;
public static class WebhookExtensions
{
public static Data.WebhookDeliveryData NewWebhookDelivery(string webhookId)
{
return new Data.WebhookDeliveryData
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)),
Timestamp = DateTimeOffset.UtcNow,
WebhookId = webhookId
};
}
public static bool ShouldDeliver(this WebhookBlob wh, string type)
{
return wh.Active && wh.AuthorizedEvents.Match(type);
}
public static IServiceCollection AddWebhooks(this IServiceCollection services)
{
services.AddSingleton<InvoiceWebhookProvider>();
services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<InvoiceWebhookProvider>());
services.AddHostedService(o => o.GetRequiredService<InvoiceWebhookProvider>());
services.AddSingleton<PayoutWebhookProvider>();
services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<PayoutWebhookProvider>());
services.AddHostedService(o => o.GetRequiredService<PayoutWebhookProvider>());
services.AddSingleton<PaymentRequestWebhookProvider>();
services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<PaymentRequestWebhookProvider>());
services.AddHostedService(o => o.GetRequiredService<PaymentRequestWebhookProvider>());
services.AddSingleton<WebhookSender>();
services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
return services;
}
}

View file

@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.HostedServices.Webhooks;
public abstract class WebhookProvider<T>(EventAggregator eventAggregator, ILogger logger, WebhookSender webhookSender)
: EventHostedServiceBase(eventAggregator, logger), IWebhookProvider
{
public abstract Dictionary<string, string> GetSupportedWebhookTypes();
protected abstract WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(T evt, WebhookData webhook);
public abstract WebhookEvent CreateTestEvent(string type, params object[] args);
protected abstract StoreWebhookEvent GetWebhookEvent(T evt);
protected override void SubscribeToEvents()
{
Subscribe<T>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is T tEvt)
{
if (GetWebhookEvent(tEvt) is not { } webhookEvent)
return;
var webhooks = await webhookSender.GetWebhooks(webhookEvent.StoreId, webhookEvent.Type);
foreach (var webhook in webhooks)
{
webhookSender.EnqueueDelivery(CreateDeliveryRequest(tEvt, webhook));
}
EventAggregator.Publish(CreateDeliveryRequest(tEvt, null));
}
await base.ProcessEvent(evt, cancellationToken);
}
}

View file

@ -0,0 +1,345 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices.Webhooks
{
/// <summary>
/// This class sends webhook notifications
/// It also makes sure the events sent to a webhook are sent in order to the webhook
/// </summary>
public class WebhookSender : IHostedService
{
public const string OnionNamedClient = "greenfield-webhook.onion";
public const string ClearnetNamedClient = "greenfield-webhook.clearnet";
public const string LoopbackNamedClient = "greenfield-webhook.loopback";
public static string[] AllClients = new[] {OnionNamedClient, ClearnetNamedClient, LoopbackNamedClient};
private readonly EventAggregator _eventAggregator;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly ILogger<WebhookSender> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly Encoding _utf8 = new UTF8Encoding(false);
public static readonly JsonSerializerSettings DefaultSerializerSettings;
private readonly MultiProcessingQueue _processingQueue = new();
private StoreRepository StoreRepository { get; }
private IHttpClientFactory HttpClientFactory { get; }
static WebhookSender()
{
DefaultSerializerSettings = WebhookEvent.DefaultSerializerSettings;
}
public WebhookSender(
StoreRepository storeRepository,
IHttpClientFactory httpClientFactory,
ApplicationDbContextFactory dbContextFactory,
ILogger<WebhookSender> logger,
IServiceProvider serviceProvider,
EventAggregator eventAggregator)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
_serviceProvider = serviceProvider;
_eventAggregator = eventAggregator;
StoreRepository = storeRepository;
HttpClientFactory = httpClientFactory;
}
private HttpClient GetClient(Uri uri)
{
return HttpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient :
uri.IsLoopback ? LoopbackNamedClient : ClearnetNamedClient);
}
public class WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent,
Data.WebhookDeliveryData delivery, WebhookBlob webhookBlob)
{
public WebhookEvent WebhookEvent { get; } = webhookEvent;
public Data.WebhookDeliveryData Delivery { get; } = delivery;
public WebhookBlob WebhookBlob { get; } = webhookBlob;
public string WebhookId { get; } = webhookId;
public virtual Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
UIStoresController.StoreEmailRule storeEmailRule)
{
return Task.FromResult(req)!;
}
protected static string InterpolateJsonField(string str, string fieldName, JObject obj)
{
fieldName += ".";
//find all instance of {fieldName*} instead str, then run obj.SelectToken(*) on it
while (true)
{
var start = str.IndexOf($"{{{fieldName}", StringComparison.InvariantCultureIgnoreCase);
if(start == -1)
break;
start += fieldName.Length + 1;
var end = str.IndexOf("}", start, StringComparison.InvariantCultureIgnoreCase);
if(end == -1)
break;
var jsonpath = str.Substring(start, end - start);
string? result = string.Empty;
try
{
if (string.IsNullOrEmpty(jsonpath))
{
result = obj.ToString();
}
else
{
result = obj.SelectToken(jsonpath)?.ToString();
}
}
catch (Exception)
{
// ignored
}
str = str.Replace($"{{{fieldName}{jsonpath}}}", result);
}
return str;
}
}
public async Task<string?> Redeliver(string deliveryId)
{
var deliveryRequest = await CreateRedeliveryRequest(deliveryId);
if (deliveryRequest is null)
return null;
EnqueueDelivery(deliveryRequest);
return deliveryRequest.Delivery.Id;
}
private async Task<WebhookDeliveryRequest?> CreateRedeliveryRequest(string deliveryId)
{
await using var ctx = _dbContextFactory.CreateContext();
var webhookDelivery = await ctx.WebhookDeliveries.AsNoTracking()
.Where(o => o.Id == deliveryId)
.Select(o => new {Webhook = o.Webhook, Delivery = o})
.FirstOrDefaultAsync();
if (webhookDelivery is null)
return null;
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
var newDelivery = WebhookExtensions.NewWebhookDelivery(webhookDelivery.Webhook.Id);
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
webhookEvent.OriginalDeliveryId ??= deliveryId;
webhookEvent.IsRedelivery = true;
newDeliveryBlob.Request = ToBytes(webhookEvent);
newDelivery.SetBlob(newDeliveryBlob);
return new WebhookDeliveryRequest(webhookDelivery.Webhook.Id, webhookEvent, newDelivery,
webhookDelivery.Webhook.GetBlob());
}
private WebhookEvent GetTestWebHook(string storeId, string webhookId, string webhookEventType,
Data.WebhookDeliveryData delivery)
{
var webhookProvider = _serviceProvider.GetServices<IWebhookProvider>()
.FirstOrDefault(provider => provider.GetSupportedWebhookTypes().ContainsKey(webhookEventType));
if (webhookProvider is null)
throw new ArgumentException($"Unknown webhook event type {webhookEventType}", webhookEventType);
var webhookEvent = webhookProvider.CreateTestEvent(webhookEventType, storeId);
if(webhookEvent is null)
throw new ArgumentException($"Webhook provider does not support tests");
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.WebhookId = webhookId;
webhookEvent.OriginalDeliveryId = "__test__" + Guid.NewGuid() + "__test__";
webhookEvent.IsRedelivery = false;
webhookEvent.Timestamp = delivery.Timestamp;
return webhookEvent;
}
public async Task<DeliveryResult> TestWebhook(string storeId, string webhookId, string webhookEventType,
CancellationToken cancellationToken)
{
var delivery = WebhookExtensions.NewWebhookDelivery(webhookId);
var webhook = (await StoreRepository.GetWebhooks(storeId)).FirstOrDefault(w => w.Id == webhookId);
var deliveryRequest = new WebhookDeliveryRequest(
webhookId,
GetTestWebHook(storeId, webhookId, webhookEventType, delivery),
delivery,
webhook.GetBlob()
);
return await SendDelivery(deliveryRequest, cancellationToken);
}
public void EnqueueDelivery(WebhookDeliveryRequest context)
{
_processingQueue.Enqueue(context.WebhookId, (cancellationToken) => Process(context, cancellationToken));
}
private async Task Process(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
{
try
{
var wh = (await StoreRepository.GetWebhook(ctx.WebhookId))?.GetBlob();
if (wh is null || !wh.ShouldDeliver(ctx.WebhookEvent.Type))
return;
var result = await SendAndSaveDelivery(ctx, cancellationToken);
if (ctx.WebhookBlob.AutomaticRedelivery &&
!result.Success &&
result.DeliveryId is not null)
{
var originalDeliveryId = result.DeliveryId;
foreach (var wait in new[]
{
TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10),
TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10),
})
{
await Task.Delay(wait, cancellationToken);
ctx = (await CreateRedeliveryRequest(originalDeliveryId))!;
// This may have changed
if (ctx is null || !ctx.WebhookBlob.AutomaticRedelivery ||
!ctx.WebhookBlob.ShouldDeliver(ctx.WebhookEvent.Type))
return;
result = await SendAndSaveDelivery(ctx, cancellationToken);
if (result.Success)
return;
}
}
}
catch when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error when processing a webhook");
}
}
public class DeliveryResult
{
public string? DeliveryId { get; set; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
private async Task<DeliveryResult> SendDelivery(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
{
var uri = new Uri(ctx.WebhookBlob.Url, UriKind.Absolute);
var httpClient = GetClient(uri);
using var request = new HttpRequestMessage();
request.RequestUri = uri;
request.Method = HttpMethod.Post;
byte[] bytes = ToBytes(ctx.WebhookEvent);
var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var hmac =
new System.Security.Cryptography.HMACSHA256(_utf8.GetBytes(ctx.WebhookBlob.Secret ?? string.Empty));
var sig = Encoders.Hex.EncodeData(hmac.ComputeHash(bytes));
content.Headers.Add("BTCPay-Sig", $"sha256={sig}");
request.Content = content;
var deliveryBlob = ctx.Delivery.GetBlob() ?? new WebhookDeliveryBlob();
deliveryBlob.Request = bytes;
try
{
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
deliveryBlob.Status = WebhookDeliveryStatus.HttpError;
deliveryBlob.ErrorMessage = $"HTTP Error Code {(int)response.StatusCode}";
}
else
{
deliveryBlob.Status = WebhookDeliveryStatus.HttpSuccess;
}
deliveryBlob.HttpCode = (int)response.StatusCode;
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
deliveryBlob.Status = WebhookDeliveryStatus.Failed;
deliveryBlob.ErrorMessage = ex.Message;
}
ctx.Delivery.SetBlob(deliveryBlob);
return new DeliveryResult()
{
Success = deliveryBlob.ErrorMessage is null,
DeliveryId = ctx.Delivery.Id,
ErrorMessage = deliveryBlob.ErrorMessage
};
}
private async Task<DeliveryResult> SendAndSaveDelivery(WebhookDeliveryRequest ctx,
CancellationToken cancellationToken)
{
var result = await SendDelivery(ctx, cancellationToken);
await StoreRepository.AddWebhookDelivery(ctx.Delivery);
return result;
}
private byte[] ToBytes(WebhookEvent webhookEvent)
{
var str = JsonConvert.SerializeObject(webhookEvent, Formatting.Indented, DefaultSerializerSettings);
var bytes = _utf8.GetBytes(str);
return bytes;
}
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
var stopping = _processingQueue.Abort(cancellationToken);
await stopping;
}
public async Task<WebhookData[]> GetWebhooks(string invoiceStoreId, string? webhookEventType)
{
return (await StoreRepository.GetWebhooks(invoiceStoreId)).Where(data => webhookEventType is null || data.GetBlob().ShouldDeliver(webhookEventType)).ToArray();
}
public async Task<UIStoresController.StoreEmailRule[]> GetEmailRules(string storeId,
string type)
{
return ( await StoreRepository.FindStore(storeId))?.GetStoreBlob().EmailRules?.Where(rule => rule.Trigger ==type).ToArray() ?? Array.Empty<UIStoresController.StoreEmailRule>();
}
public Dictionary<string, string> GetSupportedWebhookTypes()
{
return _serviceProvider.GetServices<IWebhookProvider>()
.SelectMany(provider => provider.GetSupportedWebhookTypes()).ToDictionary(pair => pair.Key, pair => pair.Value);
}
}
}

View file

@ -18,6 +18,7 @@ using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.Charge;
using BTCPayServer.Lightning.CLightning;
@ -374,11 +375,8 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
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.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
@ -386,17 +384,7 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>();
services.AddReportProvider<PayoutsReportProvider>();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddWebhooks();
services.AddSingleton<BitcoinLikePayoutHandler>();
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<LightningLikePayoutHandler>());

View file

@ -98,6 +98,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public Dictionary<string, object> FormResponse { get; set; }
public bool AmountAndCurrencyEditable { get; set; } = true;
public bool? HasEmailRules { get; set; }
}
public class ViewPaymentRequestViewModel

View file

@ -30,7 +30,7 @@ namespace BTCPayServer.Models.StoreViewModels
}
public string Id { get; set; }
public DateTimeOffset Time { get; set; }
public WebhookEventType Type { get; private set; }
public string Type { get; private set; }
public bool Pruned { get; set; }
public string WebhookId { get; set; }
public bool Success { get; set; }
@ -57,7 +57,7 @@ namespace BTCPayServer.Models.StoreViewModels
public bool Active { get; set; }
public bool AutomaticRedelivery { get; set; }
public bool Everything { get; set; }
public WebhookEventType[] Events { get; set; } = Array.Empty<WebhookEventType>();
public string[] Events { get; set; } = Array.Empty<string>();
[Uri]
[Required]
public string PayloadUrl { get; set; }

View file

@ -4,6 +4,6 @@ namespace BTCPayServer.Models.StoreViewModels
{
public class TestWebhookViewModel
{
public WebhookEventType Type { get; set; }
public string Type { get; set; }
}
}

View file

@ -16,8 +16,8 @@ namespace BTCPayServer.PaymentRequest
{
public class PaymentRequestService
{
private readonly PaymentRequestRepository _PaymentRequestRepository;
private readonly BTCPayNetworkProvider _BtcPayNetworkProvider;
private readonly PaymentRequestRepository _paymentRequestRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly InvoiceRepository _invoiceRepository;
private readonly CurrencyNameTable _currencies;
private readonly TransactionLinkProviders _transactionLinkProviders;
@ -31,8 +31,8 @@ namespace BTCPayServer.PaymentRequest
CurrencyNameTable currencies,
TransactionLinkProviders transactionLinkProviders)
{
_PaymentRequestRepository = paymentRequestRepository;
_BtcPayNetworkProvider = btcPayNetworkProvider;
_paymentRequestRepository = paymentRequestRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
_currencies = currencies;
_transactionLinkProviders = transactionLinkProviders;
@ -41,7 +41,7 @@ namespace BTCPayServer.PaymentRequest
public async Task UpdatePaymentRequestStateIfNeeded(string id)
{
var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null);
var pr = await _paymentRequestRepository.FindPaymentRequest(id, null);
await UpdatePaymentRequestStateIfNeeded(pr);
}
@ -61,7 +61,7 @@ namespace BTCPayServer.PaymentRequest
if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Expired)
{
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id);
var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id);
var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var allSettled = contributions.All(i => i.Value.States.All(s => s.IsSettled()));
var isPaid = contributions.TotalCurrency >= blob.Amount;
@ -81,13 +81,13 @@ namespace BTCPayServer.PaymentRequest
if (currentStatus != pr.Status)
{
pr.Status = currentStatus;
await _PaymentRequestRepository.UpdatePaymentRequestStatus(pr.Id, currentStatus);
await _paymentRequestRepository.UpdatePaymentRequestStatus(pr.Id, currentStatus);
}
}
public async Task<ViewPaymentRequestViewModel> GetPaymentRequest(string id, string userId = null)
{
var pr = await _PaymentRequestRepository.FindPaymentRequest(id, userId);
var pr = await _paymentRequestRepository.FindPaymentRequest(id, userId);
if (pr == null)
{
return null;
@ -95,7 +95,7 @@ namespace BTCPayServer.PaymentRequest
var blob = pr.GetBlob();
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);
var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(id);
var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var amountDue = blob.Amount - paymentStats.TotalCurrency;
var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime)

View file

@ -41,7 +41,7 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
protected readonly BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId;
private readonly IPluginHookService _pluginHookService;
private readonly EventAggregator _eventAggregator;
protected readonly EventAggregator _eventAggregator;
protected BaseAutomatedPayoutProcessor(
ILoggerFactory logger,
@ -115,7 +115,13 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
Logs.PayServer.LogInformation(
$"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
await Process(paymentMethod, payouts);
await context.SaveChangesAsync();
foreach (var payoutData in payouts.Where(payoutData => payoutData.State != PayoutState.AwaitingPayment))
{
_eventAggregator.Publish(new PayoutEvent(null, payoutData));
}
}
// Allow plugins do to something after automatic payout processing

View file

@ -27,7 +27,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain
private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler;
private readonly EventAggregator _eventAggregator;
public OnChainAutomatedPayoutProcessor(
ApplicationDbContextFactory applicationDbContextFactory,
@ -51,7 +50,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain
_btcPayWalletProvider = btcPayWalletProvider;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_bitcoinLikePayoutHandler = bitcoinLikePayoutHandler;
_eventAggregator = eventAggregator;
WalletRepository = walletRepository;
FeeProvider = feeProviderFactory.CreateFeeProvider(_btcPayNetworkProvider.GetNetwork(PaymentMethodId.CryptoCode));
}

View file

@ -1,3 +1,4 @@
using System.Threading.Tasks;
using MimeKit;
namespace BTCPayServer.Services.Mails
@ -6,5 +7,6 @@ namespace BTCPayServer.Services.Mails
{
void SendEmail(MailboxAddress email, string subject, string message);
void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message);
Task<EmailSettings> GetEmailSettings();
}
}

View file

@ -9,24 +9,41 @@ using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services.PaymentRequests
{
public record PaymentRequestEvent
{
public const string Created = nameof(Created);
public const string Updated = nameof(Updated);
public const string Archived = nameof(Archived);
public const string StatusChanged = nameof(StatusChanged);
public PaymentRequestData Data { get; set; }
public string Type { get; set; }
}
public class PaymentRequestRepository
{
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository;
private readonly EventAggregator _eventAggregator;
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory, InvoiceRepository invoiceRepository)
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
InvoiceRepository invoiceRepository, EventAggregator eventAggregator)
{
_ContextFactory = contextFactory;
_InvoiceRepository = invoiceRepository;
_eventAggregator = eventAggregator;
}
public async Task<PaymentRequestData> CreateOrUpdatePaymentRequest(PaymentRequestData entity)
{
await using var context = _ContextFactory.CreateContext();
var added = false;
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = Guid.NewGuid().ToString();
await context.PaymentRequests.AddAsync(entity);
added = true;
}
else
{
@ -34,9 +51,37 @@ namespace BTCPayServer.Services.PaymentRequests
}
await context.SaveChangesAsync();
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = entity,
Type = added ? PaymentRequestEvent.Created : PaymentRequestEvent.Updated
});
return entity;
}
public async Task<bool?> ArchivePaymentRequest(string id, bool toggle = false)
{
await using var context = _ContextFactory.CreateContext();
var pr = await context.PaymentRequests.FindAsync(id);
if(pr == null)
return null;
if(pr.Archived && !toggle)
return pr.Archived;
pr.Archived = !pr.Archived;
await context.SaveChangesAsync();
if (pr.Archived)
{
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = pr,
Type = PaymentRequestEvent.Archived
});
}
return pr.Archived;
}
public async Task<PaymentRequestData> FindPaymentRequest(string id, string userId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(id))
@ -44,7 +89,7 @@ namespace BTCPayServer.Services.PaymentRequests
return null;
}
using var context = _ContextFactory.CreateContext();
await using var context = _ContextFactory.CreateContext();
var result = await context.PaymentRequests.Include(x => x.StoreData)
.Where(data =>
string.IsNullOrEmpty(userId) ||
@ -53,27 +98,23 @@ namespace BTCPayServer.Services.PaymentRequests
return result;
}
public async Task<bool> IsPaymentRequestAdmin(string paymentRequestId, string userId)
{
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(paymentRequestId))
{
return false;
}
using var context = _ContextFactory.CreateContext();
return await context.PaymentRequests.Include(x => x.StoreData)
.AnyAsync(data =>
data.Id == paymentRequestId &&
(data.StoreData != null && data.StoreData.UserStores.Any(u => u.ApplicationUserId == userId)));
}
public async Task UpdatePaymentRequestStatus(string paymentRequestId, Client.Models.PaymentRequestData.PaymentRequestStatus status, CancellationToken cancellationToken = default)
{
using var context = _ContextFactory.CreateContext();
var invoiceData = await context.FindAsync<PaymentRequestData>(paymentRequestId);
if (invoiceData == null)
await using var context = _ContextFactory.CreateContext();
var paymentRequestData = await context.FindAsync<PaymentRequestData>(paymentRequestId);
if (paymentRequestData == null)
return;
invoiceData.Status = status;
if( paymentRequestData.Status == status)
return;
paymentRequestData.Status = status;
await context.SaveChangesAsync(cancellationToken);
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = paymentRequestData,
Type = PaymentRequestEvent.StatusChanged
});
}
public async Task<PaymentRequestData[]> FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default)

View file

@ -24,7 +24,7 @@ namespace BTCPayServer.Services.Stores
public JsonSerializerSettings SerializerSettings { get; }
public ApplicationDbContext CreateDbContext()
protected ApplicationDbContext CreateDbContext()
{
return _ContextFactory.CreateContext();
}

View file

@ -1,4 +1,3 @@
@using BTCPayServer.Abstractions.Extensions
@{
var parsedModel = TempData.GetStatusMessageModel();
}

View file

@ -7,7 +7,6 @@
@inject FormDataService FormDataService
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
@{
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id);
@ -85,7 +84,14 @@
<label asp-for="Email" class="form-label"></label>
<input type="email" asp-for="Email" placeholder="Firstname Lastname <email@example.com>" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
<div id="PaymentRequestEmailHelpBlock" class="form-text">The recipient's email. This will send notification mails to the recipient, as configured by the <a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Model.StoreId">email rules</a>, and include the email in the invoice export data.</div>
<div id="PaymentRequestEmailHelpBlock" class="form-text">
This will send notification mails to the recipient, as configured by the
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Model.StoreId">email rules</a>.
@if (Model.HasEmailRules is not true)
{
<div class="text-warning">No payment request related email rules have been configured for this store.</div>
}
</div>
</div>
<div class="form-group">
<label asp-for="FormId" class="form-label"></label>

View file

@ -1,5 +1,7 @@
@model EditWebhookViewModel
@using BTCPayServer.Client.Models;
@using BTCPayServer.HostedServices.Webhooks
@inject WebhookSender WebhookSender
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Webhooks, "Webhook Settings", Context.GetStoreData().Id);
@ -55,20 +57,11 @@
</select>
<div id="event-selector" class="collapse">
<div class="pb-3">
@foreach (var evt in new[]
{
("A new invoice has been created", WebhookEventType.InvoiceCreated),
("A new payment has been received", WebhookEventType.InvoiceReceivedPayment),
("A payment has been settled", WebhookEventType.InvoicePaymentSettled),
("An invoice is processing", WebhookEventType.InvoiceProcessing),
("An invoice has expired", WebhookEventType.InvoiceExpired),
("An invoice has been settled", WebhookEventType.InvoiceSettled),
("An invoice became invalid", WebhookEventType.InvoiceInvalid)
})
@foreach (var evt in WebhookSender.GetSupportedWebhookTypes())
{
<div class="form-check my-1">
<input name="Events" id="@evt.Item2" value="@evt.Item2" @(Model.Events.Contains(evt.Item2) ? "checked" : "") type="checkbox" class="form-check-input" />
<label for="@evt.Item2" class="form-check-label">@evt.Item1</label>
<input name="Events" id="@evt.Key" value="@evt.Key" @(Model.Events.Contains(evt.Key) ? "checked" : "") type="checkbox" class="form-check-input" />
<label for="@evt.Key" class="form-check-label">@evt.Value</label>
</div>
}
</div>

View file

@ -1,5 +1,6 @@
@using BTCPayServer.Client.Models
@using BTCPayServer.HostedServices.Webhooks
@model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel
@inject WebhookSender WebhookSender
@{
Layout = "../Shared/_NavLayout.cshtml";
@ -54,7 +55,7 @@
</button>
</div>
</div>
<select asp-for="Rules[index].Trigger" asp-items="@Html.GetEnumSelectList<WebhookEventType>()" class="form-select email-rule-trigger" required></select>
<select asp-for="Rules[index].Trigger" asp-items="@WebhookSender.GetSupportedWebhookTypes().Select(s => new SelectListItem(s.Value,s.Key))" class="form-select email-rule-trigger" required></select>
<span asp-validation-for="Rules[index].Trigger" class="text-danger"></span>
<div class="form-text">Choose what event sends the email.</div>
</div>
@ -78,17 +79,47 @@
<label asp-for="Rules[index].Body" class="form-label" data-required></label>
<textarea asp-for="Rules[index].Body" class="form-control richtext email-rule-body" rows="4"></textarea>
<span asp-validation-for="Rules[index].Body" class="text-danger"></span>
<div class="form-text d-flex gap-2">
<div>Placeholders:</div>
<div>
<code>{Invoice.Id}</code>,
<code>{Invoice.StoreId}</code>,
<code>{Invoice.Price}</code>,
<code>{Invoice.Currency}</code>,
<code>{Invoice.Status}</code>,
<code>{Invoice.AdditionalStatus}</code>,
<code>{Invoice.OrderId}</code>
</div>
<div class="form-text rounded bg-light p-2">
<table class="table table-sm caption-top m-0">
<caption class="text-muted p-0">Placeholders</caption>
<tr>
<th>Invoice</th>
<td>
<code>{Invoice.Id}</code>,
<code>{Invoice.StoreId}</code>,
<code>{Invoice.Price}</code>,
<code>{Invoice.Currency}</code>,
<code>{Invoice.Status}</code>,
<code>{Invoice.AdditionalStatus}</code>,
<code>{Invoice.OrderId}</code>
<code>{Invoice.Metadata}*</code>
</td>
</tr>
<tr>
<th>Request</th>
<td>
<code>{PaymentRequest.Id}</code>,
<code>{PaymentRequest.Price}</code>,
<code>{PaymentRequest.Currency}</code>,
<code>{PaymentRequest.Title}</code>,
<code>{PaymentRequest.Description}</code>,
<code>{PaymentRequest.Status}</code>
<code>{PaymentRequest.FormResponse}*</code>
</td>
</tr>
<tr>
<th>Payout</th>
<td>
<code>{Payout.Id}</code>,
<code>{Payout.PullPaymentId}</code>,
<code>{Payout.Destination}</code>,
<code>{Payout.State}</code>
<code>{Payout.Metadata}*</code>
</td>
</tr>
<tr><td colspan="2">* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code>
</td></tr>
</table>
</div>
</div>
</li>

View file

@ -1,5 +1,6 @@
@using BTCPayServer.HostedServices.Webhooks
@model EditWebhookViewModel
@using BTCPayServer.Client.Models;
@inject WebhookSender WebhookSender
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Webhooks, "Send a test event to a webhook endpoint", Context.GetStoreData().Id);
@ -12,7 +13,7 @@
<div class="form-group">
<label for="Type" class="form-label">Event type</label>
<select
asp-items="Html.GetEnumSelectList<WebhookEventType>()"
asp-items="@WebhookSender.GetSupportedWebhookTypes().Select(s => new SelectListItem(s.Value,s.Key))"
name="Type"
id="Type"
class="form-select w-auto"