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
47 changed files with 1265 additions and 706 deletions

1
.gitignore vendored
View File

@@ -299,3 +299,4 @@ Plugins/packed
BTCPayServer/wwwroot/swagger/v1/openapi.json 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; public bool Everything { get; set; } = true;
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] public string[] SpecificEvents { get; set; } = Array.Empty<string>();
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>();
} }
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;

View File

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

View File

@@ -1,13 +1,20 @@
namespace BTCPayServer.Client.Models namespace BTCPayServer.Client.Models;
public static class WebhookEventType
{ {
public enum WebhookEventType public const string InvoiceCreated = nameof(InvoiceCreated);
{ public const string InvoiceReceivedPayment = nameof(InvoiceReceivedPayment);
InvoiceCreated, public const string InvoiceProcessing = nameof(InvoiceProcessing);
InvoiceReceivedPayment, public const string InvoiceExpired = nameof(InvoiceExpired);
InvoiceProcessing, public const string InvoiceSettled = nameof(InvoiceSettled);
InvoiceExpired, public const string InvoiceInvalid = nameof(InvoiceInvalid);
InvoiceSettled, public const string InvoicePaymentSettled = nameof(InvoicePaymentSettled);
InvoiceInvalid, public const string PayoutCreated = nameof(PayoutCreated);
InvoicePaymentSettled, 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;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models 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()
{ {
} }
public WebhookInvoiceEvent(WebhookEventType evtType) public WebhookInvoiceEvent(string evtType, string storeId)
{ {
this.Type = evtType; 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 = 2)] public string InvoiceId { get; set; }
[JsonProperty(Order = 3)] public JObject Metadata { get; set; } [JsonProperty(Order = 3)] public JObject Metadata { get; set; }
} }
public class WebhookInvoiceSettledEvent : WebhookInvoiceEvent public class WebhookInvoiceSettledEvent : WebhookInvoiceEvent
{ {
public WebhookInvoiceSettledEvent() public WebhookInvoiceSettledEvent(string storeId) : base(WebhookEventType.InvoiceSettled, storeId)
{
}
public WebhookInvoiceSettledEvent(WebhookEventType evtType) : base(evtType)
{ {
} }
@@ -34,11 +67,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceInvalidEvent : WebhookInvoiceEvent public class WebhookInvoiceInvalidEvent : WebhookInvoiceEvent
{ {
public WebhookInvoiceInvalidEvent() public WebhookInvoiceInvalidEvent(string storeId) : base(WebhookEventType.InvoiceInvalid, storeId)
{
}
public WebhookInvoiceInvalidEvent(WebhookEventType evtType) : base(evtType)
{ {
} }
@@ -47,11 +76,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceProcessingEvent : WebhookInvoiceEvent public class WebhookInvoiceProcessingEvent : WebhookInvoiceEvent
{ {
public WebhookInvoiceProcessingEvent() public WebhookInvoiceProcessingEvent(string storeId) : base(WebhookEventType.InvoiceProcessing, storeId)
{
}
public WebhookInvoiceProcessingEvent(WebhookEventType evtType) : base(evtType)
{ {
} }
@@ -60,11 +85,7 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoiceReceivedPaymentEvent : WebhookInvoiceEvent public class WebhookInvoiceReceivedPaymentEvent : WebhookInvoiceEvent
{ {
public WebhookInvoiceReceivedPaymentEvent() public WebhookInvoiceReceivedPaymentEvent(string type, string storeId) : base(type, storeId)
{
}
public WebhookInvoiceReceivedPaymentEvent(WebhookEventType evtType) : base(evtType)
{ {
} }
@@ -76,22 +97,14 @@ namespace BTCPayServer.Client.Models
public class WebhookInvoicePaymentSettledEvent : WebhookInvoiceReceivedPaymentEvent public class WebhookInvoicePaymentSettledEvent : WebhookInvoiceReceivedPaymentEvent
{ {
public WebhookInvoicePaymentSettledEvent() public WebhookInvoicePaymentSettledEvent(string storeId) : base(WebhookEventType.InvoicePaymentSettled, storeId)
{
}
public WebhookInvoicePaymentSettledEvent(WebhookEventType evtType) : base(evtType)
{ {
} }
} }
public class WebhookInvoiceExpiredEvent : WebhookInvoiceEvent public class WebhookInvoiceExpiredEvent : WebhookInvoiceEvent
{ {
public WebhookInvoiceExpiredEvent() public WebhookInvoiceExpiredEvent(string storeId) : base(WebhookEventType.InvoiceExpired, storeId)
{
}
public WebhookInvoiceExpiredEvent(WebhookEventType evtType) : base(evtType)
{ {
} }

View File

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

View File

@@ -111,6 +111,7 @@ namespace BTCPayServer.Tests
// Payment Request // Payment Request
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click(); s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click(); s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
Thread.Sleep(10000);
s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123"); s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123");
s.Driver.FindElement(By.Id("Amount")).SendKeys("700"); s.Driver.FindElement(By.Id("Amount")).SendKeys("700");
new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email"); new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email");
@@ -461,6 +462,12 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester(); using var s = CreateSeleniumTester();
await s.StartAsync(); await s.StartAsync();
s.RegisterNewUser(true); 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 // Server Emails
s.Driver.Navigate().GoToUrl(s.Link("/server/emails")); s.Driver.Navigate().GoToUrl(s.Link("/server/emails"));
@@ -470,12 +477,11 @@ namespace BTCPayServer.Tests
s.FindAlertMessage(); s.FindAlertMessage();
} }
CanSetupEmailCore(s); CanSetupEmailCore(s);
s.CreateNewStore();
// Store Emails // Store Emails with server fallback
s.GoToStore(StoreNavPages.Emails); s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click(); 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); s.GoToStore(StoreNavPages.Emails);
CanSetupEmailCore(s); CanSetupEmailCore(s);
@@ -485,10 +491,11 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no rules yet.", s.Driver.PageSource); Assert.Contains("There are no rules yet.", s.Driver.PageSource);
Assert.DoesNotContain("id=\"SaveEmailRules\"", 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("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(); s.Driver.FindElement(By.Id("CreateEmailRule")).Click();
var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger"))); 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__To")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.Id("Rules_0__CustomerEmail")).Click(); s.Driver.FindElement(By.Id("Rules_0__CustomerEmail")).Click();
s.Driver.FindElement(By.Id("Rules_0__Subject")).SendKeys("Thanks!"); 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.Driver.FindElement(By.Name("update")).Click();
s.FindAlertMessage(); s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("Modify")).Click(); 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 // This one should be checked
Assert.Contains("value=\"InvoiceProcessing\" checked", s.Driver.PageSource); Assert.Contains("value=\"InvoiceProcessing\" checked", s.Driver.PageSource);
@@ -3052,13 +3052,18 @@ retry:
{ {
s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click(); 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.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.Id("Settings_Login")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit(); s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit();
s.FindAlertMessage(); 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_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("Settings_From")).SendKeys("Firstname Lastname <email@example.com>");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter); s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Configured", s.Driver.PageSource); 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("Settings_Login")).SendKeys("test_fix@gmail.com");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter); s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Configured", s.Driver.PageSource); 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 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; int retry = 0;
retry: retry:
@@ -520,7 +520,7 @@ retry:
} }
public async Task SetupWebhook() public async Task SetupWebhook()
{ {
FakeServer server = new FakeServer(); var server = new FakeServer();
await server.Start(); await server.Start();
var client = await CreateClient(Policies.CanModifyStoreWebhooks); var client = await CreateClient(Policies.CanModifyStoreWebhooks);
var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest() var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest()
@@ -536,7 +536,7 @@ retry:
{ {
var inv = await BitPay.GetInvoiceAsync(invoiceId); var inv = await BitPay.GetInvoiceAsync(invoiceId);
var net = parent.ExplorerNode.Network; 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 () => await TestUtils.EventuallyAsync(async () =>
{ {
var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant); var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
@@ -19,20 +20,25 @@ namespace BTCPayServer.Controllers
public partial class UIStoresController public partial class UIStoresController
{ {
[HttpGet("{storeId}/emails")] [HttpGet("{storeId}/emails")]
public IActionResult StoreEmails(string storeId) public async Task<IActionResult> StoreEmails(string storeId)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null)
return NotFound(); return NotFound();
var blob = store.GetStoreBlob(); var blob = store.GetStoreBlob();
var data = blob.EmailSettings; var storeSetupComplete = blob.EmailSettings?.IsComplete() is true;
if (data?.IsComplete() is not 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 TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Severity = StatusMessageModel.StatusSeverity.Warning, Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : 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>." 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) public async Task<IActionResult> StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command)
{ {
vm.Rules ??= new List<StoreEmailRule>(); vm.Rules ??= new List<StoreEmailRule>();
int index = 0; int commandIndex = 0;
var indSep = command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase); var indSep = command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase);
if (indSep > 0) if (indSep > 0)
{ {
var item = command[(indSep + 1)..]; var item = command[(indSep + 1)..];
index = int.Parse(item, CultureInfo.InvariantCulture); commandIndex = int.Parse(item, CultureInfo.InvariantCulture);
} }
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase)) if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{ {
vm.Rules.RemoveAt(index); vm.Rules.RemoveAt(commandIndex);
} }
else if (command == "add") else if (command == "add")
{ {
@@ -63,7 +69,7 @@ namespace BTCPayServer.Controllers
return View(vm); 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]; var rule = vm.Rules[i];
if (!rule.CustomerEmail && string.IsNullOrEmpty(rule.To)) if (!rule.CustomerEmail && string.IsNullOrEmpty(rule.To))
@@ -79,41 +85,50 @@ namespace BTCPayServer.Controllers
if (store == null) if (store == null)
return NotFound(); return NotFound();
string message = "";
// update rules
var blob = store.GetStoreBlob(); 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)) if (command.StartsWith("test", StringComparison.InvariantCultureIgnoreCase))
{ {
var rule = vm.Rules[index];
try try
{ {
var emailSettings = blob.EmailSettings; var rule = vm.Rules[commandIndex];
using var client = await emailSettings.CreateSmtpClient(); var emailSender = await _emailSenderFactory.GetEmailSender(store.Id);
var message = emailSettings.CreateMailMessage(MailboxAddress.Parse(rule.To), "(test) " + rule.Subject, rule.Body, true); if (await IsSetupComplete(emailSender))
await client.SendAsync(message); {
await client.DisconnectAsync(true); emailSender.SendEmail(MailboxAddress.Parse(rule.To), $"({store.StoreName} test) {rule.Subject}", rule.Body);
TempData[WellKnownTempData.SuccessMessage] = $"Rule email saved and sent to {rule.To}. Please verify you received it."; message += $"Test email sent to {rule.To} — please verify you received it.";
}
blob.EmailRules = vm.Rules; else
store.SetStoreBlob(blob); {
await _Repo.UpdateStore(store); message += "Complete the email setup to send test emails.";
}
} }
catch (Exception ex) 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 TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Severity = StatusMessageModel.StatusSeverity.Success, Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Store email rules saved" Message = message
}); });
} }
return RedirectToAction("StoreEmails", new { storeId }); return RedirectToAction("StoreEmails", new { storeId });
} }
@@ -125,7 +140,7 @@ namespace BTCPayServer.Controllers
public class StoreEmailRule public class StoreEmailRule
{ {
[Required] [Required]
public WebhookEventType Trigger { get; set; } public string Trigger { get; set; }
public bool CustomerEmail { get; set; } public bool CustomerEmail { get; set; }
@@ -209,5 +224,10 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(StoreEmailSettings), new { storeId }); 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.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
@@ -22,6 +23,7 @@ using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
@@ -68,7 +70,8 @@ namespace BTCPayServer.Controllers
IOptions<LightningNetworkOptions> lightningNetworkOptions, IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions, IOptions<ExternalServicesOptions> externalServiceOptions,
IHtmlHelper html, IHtmlHelper html,
LightningClientFactoryService lightningClientFactoryService) LightningClientFactoryService lightningClientFactoryService,
EmailSenderFactory emailSenderFactory)
{ {
_RateFactory = rateFactory; _RateFactory = rateFactory;
_Repo = repo; _Repo = repo;
@@ -93,6 +96,7 @@ namespace BTCPayServer.Controllers
_BTCPayEnv = btcpayEnv; _BTCPayEnv = btcpayEnv;
_externalServiceOptions = externalServiceOptions; _externalServiceOptions = externalServiceOptions;
_lightningClientFactoryService = lightningClientFactoryService; _lightningClientFactoryService = lightningClientFactoryService;
_emailSenderFactory = emailSenderFactory;
Html = html; Html = html;
} }
@@ -116,6 +120,7 @@ namespace BTCPayServer.Controllers
private readonly EventAggregator _EventAggregator; private readonly EventAggregator _EventAggregator;
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions; private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
private readonly LightningClientFactoryService _lightningClientFactoryService; private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly EmailSenderFactory _emailSenderFactory;
public string? GeneratedPairingCode { get; set; } public string? GeneratedPairingCode { get; set; }
public WebhookSender WebhookNotificationManager { get; } public WebhookSender WebhookNotificationManager { get; }

View File

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

View File

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

View File

@@ -5,10 +5,10 @@ using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices.Webhooks;
using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.CSharp.Syntax;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using SshNet.Security.Cryptography;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
@@ -16,9 +16,8 @@ namespace BTCPayServer.Data
{ {
public bool Everything { get; set; } public bool Everything { get; set; }
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] public string[] SpecificEvents { get; set; } = Array.Empty<string>();
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>(); public bool Match(string evt)
public bool Match(WebhookEventType evt)
{ {
return Everything || SpecificEvents.Contains(evt); return Everything || SpecificEvents.Contains(evt);
} }
@@ -47,7 +46,7 @@ namespace BTCPayServer.Data
} }
public T ReadRequestAs<T>() 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() public bool IsPruned()
@@ -78,14 +77,14 @@ namespace BTCPayServer.Data
if (webhook.Blob is null) if (webhook.Blob is null)
return null; return null;
else 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) public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
{ {
if (blob is null) if (blob is null)
webhook.Blob = null; webhook.Blob = null;
else 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"; 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 NotificationType => TYPE;
public override (string identifier, string name)[] Meta public override (string identifier, string name)[] Meta
@@ -48,9 +39,9 @@ namespace BTCPayServer.HostedServices
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.Body = $"New {notification.Name} plugin version {notification.Version} released!"; 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", "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 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) public async Task Do(CancellationToken cancellationToken)
{ {
var dh = await _settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ?? var dh = await settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder(); new PluginVersionCheckerDataHolder();
dh.LastVersions ??= new Dictionary<string, Version>(); dh.LastVersions ??= new Dictionary<string, Version>();
var disabledPlugins = _pluginService.GetDisabledPlugins(); var disabledPlugins = pluginService.GetDisabledPlugins();
var installedPlugins = var installedPlugins =
_pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var remotePlugins = await _pluginService.GetRemotePlugins(); var remotePlugins = await pluginService.GetRemotePlugins();
var remotePluginsList = remotePlugins var remotePluginsList = remotePlugins
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name)) .Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name))
.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); .ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
@@ -128,10 +104,10 @@ namespace BTCPayServer.HostedServices
foreach (string pluginUpdate in notify) foreach (string pluginUpdate in notify)
{ {
var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate); 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; payout.State = req.Request.State;
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(null, payout));
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok); req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok);
} }
catch (Exception ex) catch (Exception ex)
@@ -711,6 +712,11 @@ namespace BTCPayServer.HostedServices
} }
await ctx.SaveChangesAsync(); 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); cancel.Completion.TrySetResult(result);
} }
catch (Exception ex) catch (Exception ex)
@@ -929,13 +935,13 @@ namespace BTCPayServer.HostedServices
public JObject Metadata { get; set; } public JObject Metadata { get; set; }
} }
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout) public record PayoutEvent(PayoutEvent.PayoutEventType? Type, PayoutData Payout)
{ {
public enum PayoutEventType public enum PayoutEventType
{ {
Created, Created,
Approved Approved,
Updated
} }
} }
} }

View File

@@ -1,20 +1,14 @@
using System; using System;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
namespace BTCPayServer.HostedServices; namespace BTCPayServer.HostedServices;
@@ -22,82 +16,75 @@ public class StoreEmailRuleProcessorSender : EventHostedServiceBase
{ {
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly EmailSenderFactory _emailSenderFactory; private readonly EmailSenderFactory _emailSenderFactory;
private readonly LinkGenerator _linkGenerator;
private readonly CurrencyNameTable _currencyNameTable;
public StoreEmailRuleProcessorSender(StoreRepository storeRepository, EventAggregator eventAggregator, public StoreEmailRuleProcessorSender(StoreRepository storeRepository, EventAggregator eventAggregator,
ILogger<InvoiceEventSaverService> logger, ILogger<InvoiceEventSaverService> logger,
EmailSenderFactory emailSenderFactory, EmailSenderFactory emailSenderFactory) : base(
LinkGenerator linkGenerator,
CurrencyNameTable currencyNameTable) : base(
eventAggregator, logger) eventAggregator, logger)
{ {
_storeRepository = storeRepository; _storeRepository = storeRepository;
_emailSenderFactory = emailSenderFactory; _emailSenderFactory = emailSenderFactory;
_linkGenerator = linkGenerator;
_currencyNameTable = currencyNameTable;
} }
protected override void SubscribeToEvents() protected override void SubscribeToEvents()
{ {
Subscribe<InvoiceEvent>(); Subscribe<WebhookSender.WebhookDeliveryRequest>();
} }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) 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) if (type is null)
{ {
return; 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(); var blob = store.GetStoreBlob();
if (blob.EmailRules?.Any() is true) 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()) if (actionableRules.Any())
{ {
var sender = await _emailSenderFactory.GetEmailSender(invoiceEvent.Invoice.StoreId); var sender = await _emailSenderFactory.GetEmailSender(storeWebhookEvent.StoreId);
foreach (UIStoresController.StoreEmailRule actionableRule in actionableRules) 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 => .Select(o =>
{ {
MailboxAddressValidator.TryParse(o, out var mb); MailboxAddressValidator.TryParse(o, out var mb);
return mb; return mb;
}) })
.Where(o => o != null) .Where(o => o != null)
.ToList(); .ToArray();
if (actionableRule.CustomerEmail &&
MailboxAddressValidator.TryParse(invoiceEvent.Invoice.Metadata.BuyerEmail, out var bmb)) if(recipients.Length == 0)
{ continue;
recipients.Add(bmb);
} sender.SendEmail(recipients.ToArray(), null, null, request.Subject, request.Body);
var i = GreenfieldInvoiceController.ToModel(invoiceEvent.Invoice, _linkGenerator, null);
sender.SendEmail(recipients.ToArray(), null, null, Interpolator(actionableRule.Subject, i),
Interpolator(actionableRule.Body, i));
} }
} }
} }
} }
} }
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.Data.Payouts.LightningLike;
using BTCPayServer.Forms; using BTCPayServer.Forms;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Lightning.Charge; using BTCPayServer.Lightning.Charge;
using BTCPayServer.Lightning.CLightning; using BTCPayServer.Lightning.CLightning;
@@ -374,11 +375,8 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>()); services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
services.AddSingleton<HostedServices.CheckConfigurationHostedService>(); services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<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, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>(); services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1)); services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1)); services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
@@ -386,17 +384,7 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<OnChainWalletReportProvider>(); services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>(); services.AddReportProvider<ProductsReportProvider>();
services.AddReportProvider<PayoutsReportProvider>(); services.AddReportProvider<PayoutsReportProvider>();
services.AddWebhooks();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
services.AddSingleton<BitcoinLikePayoutHandler>(); services.AddSingleton<BitcoinLikePayoutHandler>();
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>()); services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<LightningLikePayoutHandler>()); 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 Dictionary<string, object> FormResponse { get; set; }
public bool AmountAndCurrencyEditable { get; set; } = true; public bool AmountAndCurrencyEditable { get; set; } = true;
public bool? HasEmailRules { get; set; }
} }
public class ViewPaymentRequestViewModel public class ViewPaymentRequestViewModel

View File

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

View File

@@ -4,6 +4,6 @@ namespace BTCPayServer.Models.StoreViewModels
{ {
public class TestWebhookViewModel 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 public class PaymentRequestService
{ {
private readonly PaymentRequestRepository _PaymentRequestRepository; private readonly PaymentRequestRepository _paymentRequestRepository;
private readonly BTCPayNetworkProvider _BtcPayNetworkProvider; private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly InvoiceRepository _invoiceRepository; private readonly InvoiceRepository _invoiceRepository;
private readonly CurrencyNameTable _currencies; private readonly CurrencyNameTable _currencies;
private readonly TransactionLinkProviders _transactionLinkProviders; private readonly TransactionLinkProviders _transactionLinkProviders;
@@ -31,8 +31,8 @@ namespace BTCPayServer.PaymentRequest
CurrencyNameTable currencies, CurrencyNameTable currencies,
TransactionLinkProviders transactionLinkProviders) TransactionLinkProviders transactionLinkProviders)
{ {
_PaymentRequestRepository = paymentRequestRepository; _paymentRequestRepository = paymentRequestRepository;
_BtcPayNetworkProvider = btcPayNetworkProvider; _btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository; _invoiceRepository = invoiceRepository;
_currencies = currencies; _currencies = currencies;
_transactionLinkProviders = transactionLinkProviders; _transactionLinkProviders = transactionLinkProviders;
@@ -41,7 +41,7 @@ namespace BTCPayServer.PaymentRequest
public async Task UpdatePaymentRequestStateIfNeeded(string id) public async Task UpdatePaymentRequestStateIfNeeded(string id)
{ {
var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null); var pr = await _paymentRequestRepository.FindPaymentRequest(id, null);
await UpdatePaymentRequestStateIfNeeded(pr); await UpdatePaymentRequestStateIfNeeded(pr);
} }
@@ -61,7 +61,7 @@ namespace BTCPayServer.PaymentRequest
if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Expired) 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 contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var allSettled = contributions.All(i => i.Value.States.All(s => s.IsSettled())); var allSettled = contributions.All(i => i.Value.States.All(s => s.IsSettled()));
var isPaid = contributions.TotalCurrency >= blob.Amount; var isPaid = contributions.TotalCurrency >= blob.Amount;
@@ -81,13 +81,13 @@ namespace BTCPayServer.PaymentRequest
if (currentStatus != pr.Status) if (currentStatus != pr.Status)
{ {
pr.Status = currentStatus; 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) 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) if (pr == null)
{ {
return null; return null;
@@ -95,7 +95,7 @@ namespace BTCPayServer.PaymentRequest
var blob = pr.GetBlob(); 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 paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var amountDue = blob.Amount - paymentStats.TotalCurrency; var amountDue = blob.Amount - paymentStats.TotalCurrency;
var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime) 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 BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId; protected readonly PaymentMethodId PaymentMethodId;
private readonly IPluginHookService _pluginHookService; private readonly IPluginHookService _pluginHookService;
private readonly EventAggregator _eventAggregator; protected readonly EventAggregator _eventAggregator;
protected BaseAutomatedPayoutProcessor( protected BaseAutomatedPayoutProcessor(
ILoggerFactory logger, ILoggerFactory logger,
@@ -115,7 +115,13 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
Logs.PayServer.LogInformation( Logs.PayServer.LogInformation(
$"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})"); $"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
await Process(paymentMethod, payouts); await Process(paymentMethod, payouts);
await context.SaveChangesAsync(); 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 // 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 BTCPayWalletProvider _btcPayWalletProvider;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler; private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler;
private readonly EventAggregator _eventAggregator;
public OnChainAutomatedPayoutProcessor( public OnChainAutomatedPayoutProcessor(
ApplicationDbContextFactory applicationDbContextFactory, ApplicationDbContextFactory applicationDbContextFactory,
@@ -51,7 +50,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain
_btcPayWalletProvider = btcPayWalletProvider; _btcPayWalletProvider = btcPayWalletProvider;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_bitcoinLikePayoutHandler = bitcoinLikePayoutHandler; _bitcoinLikePayoutHandler = bitcoinLikePayoutHandler;
_eventAggregator = eventAggregator;
WalletRepository = walletRepository; WalletRepository = walletRepository;
FeeProvider = feeProviderFactory.CreateFeeProvider(_btcPayNetworkProvider.GetNetwork(PaymentMethodId.CryptoCode)); FeeProvider = feeProviderFactory.CreateFeeProvider(_btcPayNetworkProvider.GetNetwork(PaymentMethodId.CryptoCode));
} }

View File

@@ -1,3 +1,4 @@
using System.Threading.Tasks;
using MimeKit; using MimeKit;
namespace BTCPayServer.Services.Mails namespace BTCPayServer.Services.Mails
@@ -6,5 +7,6 @@ namespace BTCPayServer.Services.Mails
{ {
void SendEmail(MailboxAddress email, string subject, string message); void SendEmail(MailboxAddress email, string subject, string message);
void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, 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 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 public class PaymentRequestRepository
{ {
private readonly ApplicationDbContextFactory _ContextFactory; private readonly ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository; private readonly InvoiceRepository _InvoiceRepository;
private readonly EventAggregator _eventAggregator;
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory, InvoiceRepository invoiceRepository) public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
InvoiceRepository invoiceRepository, EventAggregator eventAggregator)
{ {
_ContextFactory = contextFactory; _ContextFactory = contextFactory;
_InvoiceRepository = invoiceRepository; _InvoiceRepository = invoiceRepository;
_eventAggregator = eventAggregator;
} }
public async Task<PaymentRequestData> CreateOrUpdatePaymentRequest(PaymentRequestData entity) public async Task<PaymentRequestData> CreateOrUpdatePaymentRequest(PaymentRequestData entity)
{ {
await using var context = _ContextFactory.CreateContext(); await using var context = _ContextFactory.CreateContext();
var added = false;
if (string.IsNullOrEmpty(entity.Id)) if (string.IsNullOrEmpty(entity.Id))
{ {
entity.Id = Guid.NewGuid().ToString(); entity.Id = Guid.NewGuid().ToString();
await context.PaymentRequests.AddAsync(entity); await context.PaymentRequests.AddAsync(entity);
added = true;
} }
else else
{ {
@@ -34,9 +51,37 @@ namespace BTCPayServer.Services.PaymentRequests
} }
await context.SaveChangesAsync(); await context.SaveChangesAsync();
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = entity,
Type = added ? PaymentRequestEvent.Created : PaymentRequestEvent.Updated
});
return entity; 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) public async Task<PaymentRequestData> FindPaymentRequest(string id, string userId, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrEmpty(id)) if (string.IsNullOrEmpty(id))
@@ -44,7 +89,7 @@ namespace BTCPayServer.Services.PaymentRequests
return null; return null;
} }
using var context = _ContextFactory.CreateContext(); await using var context = _ContextFactory.CreateContext();
var result = await context.PaymentRequests.Include(x => x.StoreData) var result = await context.PaymentRequests.Include(x => x.StoreData)
.Where(data => .Where(data =>
string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userId) ||
@@ -53,27 +98,23 @@ namespace BTCPayServer.Services.PaymentRequests
return result; 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) public async Task UpdatePaymentRequestStatus(string paymentRequestId, Client.Models.PaymentRequestData.PaymentRequestStatus status, CancellationToken cancellationToken = default)
{ {
using var context = _ContextFactory.CreateContext(); await using var context = _ContextFactory.CreateContext();
var invoiceData = await context.FindAsync<PaymentRequestData>(paymentRequestId); var paymentRequestData = await context.FindAsync<PaymentRequestData>(paymentRequestId);
if (invoiceData == null) if (paymentRequestData == null)
return; return;
invoiceData.Status = status; if( paymentRequestData.Status == status)
return;
paymentRequestData.Status = status;
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = paymentRequestData,
Type = PaymentRequestEvent.StatusChanged
});
} }
public async Task<PaymentRequestData[]> FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default) 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 JsonSerializerSettings SerializerSettings { get; }
public ApplicationDbContext CreateDbContext() protected ApplicationDbContext CreateDbContext()
{ {
return _ContextFactory.CreateContext(); return _ContextFactory.CreateContext();
} }

View File

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

View File

@@ -7,7 +7,6 @@
@inject FormDataService FormDataService @inject FormDataService FormDataService
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel @model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
@{ @{
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId); var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id); 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> <label asp-for="Email" class="form-label"></label>
<input type="email" asp-for="Email" placeholder="Firstname Lastname <email@example.com>" class="form-control" /> <input type="email" asp-for="Email" placeholder="Firstname Lastname <email@example.com>" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span> <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>
<div class="form-group"> <div class="form-group">
<label asp-for="FormId" class="form-label"></label> <label asp-for="FormId" class="form-label"></label>

View File

@@ -1,5 +1,7 @@
@model EditWebhookViewModel @model EditWebhookViewModel
@using BTCPayServer.Client.Models; @using BTCPayServer.Client.Models;
@using BTCPayServer.HostedServices.Webhooks
@inject WebhookSender WebhookSender
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Webhooks, "Webhook Settings", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.Webhooks, "Webhook Settings", Context.GetStoreData().Id);
@@ -55,20 +57,11 @@
</select> </select>
<div id="event-selector" class="collapse"> <div id="event-selector" class="collapse">
<div class="pb-3"> <div class="pb-3">
@foreach (var evt in new[] @foreach (var evt in WebhookSender.GetSupportedWebhookTypes())
{
("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)
})
{ {
<div class="form-check my-1"> <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" /> <input name="Events" id="@evt.Key" value="@evt.Key" @(Model.Events.Contains(evt.Key) ? "checked" : "") type="checkbox" class="form-check-input" />
<label for="@evt.Item2" class="form-check-label">@evt.Item1</label> <label for="@evt.Key" class="form-check-label">@evt.Value</label>
</div> </div>
} }
</div> </div>

View File

@@ -1,5 +1,6 @@
@using BTCPayServer.Client.Models @using BTCPayServer.HostedServices.Webhooks
@model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel @model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel
@inject WebhookSender WebhookSender
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
@@ -54,7 +55,7 @@
</button> </button>
</div> </div>
</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> <span asp-validation-for="Rules[index].Trigger" class="text-danger"></span>
<div class="form-text">Choose what event sends the email.</div> <div class="form-text">Choose what event sends the email.</div>
</div> </div>
@@ -78,17 +79,47 @@
<label asp-for="Rules[index].Body" class="form-label" data-required></label> <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> <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> <span asp-validation-for="Rules[index].Body" class="text-danger"></span>
<div class="form-text d-flex gap-2"> <div class="form-text rounded bg-light p-2">
<div>Placeholders:</div> <table class="table table-sm caption-top m-0">
<div> <caption class="text-muted p-0">Placeholders</caption>
<code>{Invoice.Id}</code>, <tr>
<code>{Invoice.StoreId}</code>, <th>Invoice</th>
<code>{Invoice.Price}</code>, <td>
<code>{Invoice.Currency}</code>, <code>{Invoice.Id}</code>,
<code>{Invoice.Status}</code>, <code>{Invoice.StoreId}</code>,
<code>{Invoice.AdditionalStatus}</code>, <code>{Invoice.Price}</code>,
<code>{Invoice.OrderId}</code> <code>{Invoice.Currency}</code>,
</div> <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>
</div> </div>
</li> </li>

View File

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