From a97172cea6b9b51018b7781300fe12bda85f6e37 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Fri, 1 Dec 2023 10:50:05 +0100 Subject: [PATCH] Pluginize Webhooks and support Payouts (#5421) Co-authored-by: d11n --- .gitignore | 3 +- .../Models/StoreWebhookData.cs | 3 +- BTCPayServer.Client/Models/WebhookEvent.cs | 5 +- .../Models/WebhookEventType.cs | 29 +- .../Models/WebhookInvoiceEvent.cs | 83 ++-- BTCPayServer.Tests/GreenfieldAPITests.cs | 26 +- BTCPayServer.Tests/SeleniumTests.cs | 27 +- BTCPayServer.Tests/TestAccount.cs | 6 +- BTCPayServer.Tests/UnitTest1.cs | 29 +- .../GreenfieldPaymentRequestsController.cs | 4 +- .../GreenfieldStoreWebhooksController.cs | 1 + .../Controllers/UIInvoiceController.cs | 1 + .../Controllers/UIPaymentRequestController.cs | 24 +- .../Controllers/UIStoresController.Email.cs | 76 ++-- .../Controllers/UIStoresController.cs | 7 +- .../BitcoinLike/BitcoinLikePayoutHandler.cs | 18 +- .../UILightningLikePayoutController.cs | 17 +- BTCPayServer/Data/WebhookDataExtensions.cs | 13 +- .../HostedServices/PluginUpdateFetcher.cs | 46 +-- .../PullPaymentHostedService.cs | 12 +- .../StoreEmailRuleProcessorSender.cs | 79 ++-- BTCPayServer/HostedServices/WebhookSender.cs | 390 ------------------ .../Webhooks/IWebhookProvider.cs | 12 + .../Webhooks/InvoiceWebhookDeliveryRequest.cs | 53 +++ .../Webhooks/InvoiceWebhookProvider.cs | 120 ++++++ .../PaymentRequestWebhookDeliveryRequest.cs | 50 +++ .../Webhooks/PaymentRequestWebhookProvider.cs | 67 +++ .../Webhooks/PayoutWebhookDeliveryRequest.cs | 36 ++ .../Webhooks/PayoutWebhookProvider.cs | 64 +++ .../Webhooks/WebhookExtensions.cs | 56 +++ .../Webhooks/WebhookProvider.cs | 45 ++ .../HostedServices/Webhooks/WebhookSender.cs | 345 ++++++++++++++++ BTCPayServer/Hosting/BTCPayServerServices.cs | 16 +- .../ListPaymentRequestsViewModel.cs | 1 + .../StoreViewModels/EditWebhookViewModel.cs | 4 +- .../StoreViewModels/TestWebhookViewModel.cs | 2 +- .../PaymentRequest/PaymentRequestService.cs | 18 +- .../BaseAutomatedPayoutProcessor.cs | 8 +- .../OnChainAutomatedPayoutProcessor.cs | 2 - BTCPayServer/Services/Mails/IEmailSender.cs | 2 + .../PaymentRequestRepository.cs | 79 +++- .../Services/Stores/StoreRepository.cs | 2 +- .../Views/Shared/_StatusMessage.cshtml | 1 - .../EditPaymentRequest.cshtml | 10 +- .../Views/UIStores/ModifyWebhook.cshtml | 17 +- .../Views/UIStores/StoreEmails.cshtml | 57 ++- .../Views/UIStores/TestWebhook.cshtml | 5 +- 47 files changed, 1265 insertions(+), 706 deletions(-) delete mode 100644 BTCPayServer/HostedServices/WebhookSender.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs create mode 100644 BTCPayServer/HostedServices/Webhooks/WebhookSender.cs diff --git a/.gitignore b/.gitignore index 72025a549..8fcf0213b 100644 --- a/.gitignore +++ b/.gitignore @@ -298,4 +298,5 @@ Packed Plugins Plugins/packed BTCPayServer/wwwroot/swagger/v1/openapi.json -BTCPayServer/appsettings.dev.json \ No newline at end of file +BTCPayServer/appsettings.dev.json +BTCPayServer.Tests/monero_wallet diff --git a/BTCPayServer.Client/Models/StoreWebhookData.cs b/BTCPayServer.Client/Models/StoreWebhookData.cs index 497bd8fd3..5fce44759 100644 --- a/BTCPayServer.Client/Models/StoreWebhookData.cs +++ b/BTCPayServer.Client/Models/StoreWebhookData.cs @@ -11,8 +11,7 @@ namespace BTCPayServer.Client.Models { public bool Everything { get; set; } = true; - [JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] - public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty(); + public string[] SpecificEvents { get; set; } = Array.Empty(); } public bool Enabled { get; set; } = true; diff --git a/BTCPayServer.Client/Models/WebhookEvent.cs b/BTCPayServer.Client/Models/WebhookEvent.cs index 1b43dbc34..e8c130dad 100644 --- a/BTCPayServer.Client/Models/WebhookEvent.cs +++ b/BTCPayServer.Client/Models/WebhookEvent.cs @@ -9,7 +9,7 @@ namespace BTCPayServer.Client.Models { public class WebhookEvent { - public readonly static JsonSerializerSettings DefaultSerializerSettings; + public static readonly JsonSerializerSettings DefaultSerializerSettings; static WebhookEvent() { DefaultSerializerSettings = new JsonSerializerSettings(); @@ -45,8 +45,7 @@ namespace BTCPayServer.Client.Models } } public bool IsRedelivery { get; set; } - [JsonConverter(typeof(StringEnumConverter))] - public WebhookEventType Type { get; set; } + public string Type { get; set; } [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] public DateTimeOffset Timestamp { get; set; } [JsonExtensionData] diff --git a/BTCPayServer.Client/Models/WebhookEventType.cs b/BTCPayServer.Client/Models/WebhookEventType.cs index 147b2dd3a..76afc5bd7 100644 --- a/BTCPayServer.Client/Models/WebhookEventType.cs +++ b/BTCPayServer.Client/Models/WebhookEventType.cs @@ -1,13 +1,20 @@ -namespace BTCPayServer.Client.Models +namespace BTCPayServer.Client.Models; + +public static class WebhookEventType { - public enum WebhookEventType - { - InvoiceCreated, - InvoiceReceivedPayment, - InvoiceProcessing, - InvoiceExpired, - InvoiceSettled, - InvoiceInvalid, - InvoicePaymentSettled, - } + public const string InvoiceCreated = nameof(InvoiceCreated); + public const string InvoiceReceivedPayment = nameof(InvoiceReceivedPayment); + public const string InvoiceProcessing = nameof(InvoiceProcessing); + public const string InvoiceExpired = nameof(InvoiceExpired); + public const string InvoiceSettled = nameof(InvoiceSettled); + public const string InvoiceInvalid = nameof(InvoiceInvalid); + public const string InvoicePaymentSettled = nameof(InvoicePaymentSettled); + public const string PayoutCreated = nameof(PayoutCreated); + public const string PayoutApproved = nameof(PayoutApproved); + public const string PayoutUpdated = nameof(PayoutUpdated); + public const string PaymentRequestUpdated = nameof(PaymentRequestUpdated); + public const string PaymentRequestCreated = nameof(PaymentRequestCreated); + public const string PaymentRequestArchived = nameof(PaymentRequestArchived); + public const string PaymentRequestStatusChanged = nameof(PaymentRequestStatusChanged); + } diff --git a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs index fff25d13c..e9fe448aa 100644 --- a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs +++ b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs @@ -1,31 +1,64 @@ +using System; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; namespace BTCPayServer.Client.Models { - public class WebhookInvoiceEvent : WebhookEvent + public class WebhookPayoutEvent : StoreWebhookEvent + { + public WebhookPayoutEvent(string evtType, string storeId) + { + if (!evtType.StartsWith("payout", StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException("Invalid event type", nameof(evtType)); + Type = evtType; + StoreId = storeId; + } + + [JsonProperty(Order = 2)] public string PayoutId { get; set; } + [JsonProperty(Order = 3)] public string PullPaymentId { get; set; } + [JsonProperty(Order = 4)] [JsonConverter(typeof(StringEnumConverter))]public PayoutState PayoutState { get; set; } + } + public class WebhookPaymentRequestEvent : StoreWebhookEvent + { + public WebhookPaymentRequestEvent(string evtType, string storeId) + { + if (!evtType.StartsWith("paymentrequest", StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException("Invalid event type", nameof(evtType)); + Type = evtType; + StoreId = storeId; + } + + [JsonProperty(Order = 2)] public string PaymentRequestId { get; set; } + [JsonProperty(Order = 3)] [JsonConverter(typeof(StringEnumConverter))]public PaymentRequestData.PaymentRequestStatus Status { get; set; } + } + + public abstract class StoreWebhookEvent : WebhookEvent + { + [JsonProperty(Order = 1)] public string StoreId { get; set; } + } + + public class WebhookInvoiceEvent : StoreWebhookEvent { public WebhookInvoiceEvent() { } - public WebhookInvoiceEvent(WebhookEventType evtType) - { - this.Type = evtType; + public WebhookInvoiceEvent(string evtType, string storeId) + { + if (!evtType.StartsWith("invoice", StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException("Invalid event type", nameof(evtType)); + Type = evtType; + StoreId = storeId; } - [JsonProperty(Order = 1)] public string StoreId { get; set; } [JsonProperty(Order = 2)] public string InvoiceId { get; set; } [JsonProperty(Order = 3)] public JObject Metadata { get; set; } } public class WebhookInvoiceSettledEvent : WebhookInvoiceEvent { - public WebhookInvoiceSettledEvent() - { - } - - public WebhookInvoiceSettledEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoiceSettledEvent(string storeId) : base(WebhookEventType.InvoiceSettled, storeId) { } @@ -34,11 +67,7 @@ namespace BTCPayServer.Client.Models public class WebhookInvoiceInvalidEvent : WebhookInvoiceEvent { - public WebhookInvoiceInvalidEvent() - { - } - - public WebhookInvoiceInvalidEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoiceInvalidEvent(string storeId) : base(WebhookEventType.InvoiceInvalid, storeId) { } @@ -47,11 +76,7 @@ namespace BTCPayServer.Client.Models public class WebhookInvoiceProcessingEvent : WebhookInvoiceEvent { - public WebhookInvoiceProcessingEvent() - { - } - - public WebhookInvoiceProcessingEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoiceProcessingEvent(string storeId) : base(WebhookEventType.InvoiceProcessing, storeId) { } @@ -60,11 +85,7 @@ namespace BTCPayServer.Client.Models public class WebhookInvoiceReceivedPaymentEvent : WebhookInvoiceEvent { - public WebhookInvoiceReceivedPaymentEvent() - { - } - - public WebhookInvoiceReceivedPaymentEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoiceReceivedPaymentEvent(string type, string storeId) : base(type, storeId) { } @@ -76,22 +97,14 @@ namespace BTCPayServer.Client.Models public class WebhookInvoicePaymentSettledEvent : WebhookInvoiceReceivedPaymentEvent { - public WebhookInvoicePaymentSettledEvent() - { - } - - public WebhookInvoicePaymentSettledEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoicePaymentSettledEvent(string storeId) : base(WebhookEventType.InvoicePaymentSettled, storeId) { } } public class WebhookInvoiceExpiredEvent : WebhookInvoiceEvent { - public WebhookInvoiceExpiredEvent() - { - } - - public WebhookInvoiceExpiredEvent(WebhookEventType evtType) : base(evtType) + public WebhookInvoiceExpiredEvent(string storeId) : base(WebhookEventType.InvoiceExpired, storeId) { } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 375013525..44f4e7eec 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2099,18 +2099,18 @@ namespace BTCPayServer.Tests //validation errors await AssertValidationError(new[] { nameof(CreateInvoiceRequest.Amount), $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentTolerance)}", $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentMethods)}[0]" }, async () => { - await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions() { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } }); + await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } }); }); await AssertHttpError(403, async () => { await viewOnly.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() { Currency = "helloinvalid", Amount = 1 }); + new CreateInvoiceRequest { Currency = "helloinvalid", Amount = 1 }); }); await user.RegisterDerivationSchemeAsync("BTC"); string origOrderId = "testOrder"; var newInvoice = await client.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() + new CreateInvoiceRequest { Currency = "USD", Amount = 1, @@ -2197,7 +2197,7 @@ namespace BTCPayServer.Tests //list NonExisting Status var invoicesNonExistingStatus = await viewOnly.GetInvoices(user.StoreId, - status: new[] { BTCPayServer.Client.Models.InvoiceStatus.Invalid }); + status: new[] { InvoiceStatus.Invalid }); Assert.NotNull(invoicesNonExistingStatus); Assert.Empty(invoicesNonExistingStatus); @@ -2215,7 +2215,7 @@ namespace BTCPayServer.Tests //update newInvoice = await client.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() { Currency = "USD", Amount = 1 }); + new CreateInvoiceRequest { Currency = "USD", Amount = 1 }); Assert.Contains(InvoiceStatus.Settled, newInvoice.AvailableStatusesForManualMarking); Assert.Contains(InvoiceStatus.Invalid, newInvoice.AvailableStatusesForManualMarking); await client.MarkInvoiceStatus(user.StoreId, newInvoice.Id, new MarkInvoiceStatusRequest() @@ -2227,7 +2227,7 @@ namespace BTCPayServer.Tests Assert.DoesNotContain(InvoiceStatus.Settled, newInvoice.AvailableStatusesForManualMarking); Assert.Contains(InvoiceStatus.Invalid, newInvoice.AvailableStatusesForManualMarking); newInvoice = await client.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() { Currency = "USD", Amount = 1 }); + new CreateInvoiceRequest { Currency = "USD", Amount = 1 }); await client.MarkInvoiceStatus(user.StoreId, newInvoice.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Invalid @@ -2242,13 +2242,13 @@ namespace BTCPayServer.Tests await AssertHttpError(403, async () => { await viewOnly.UpdateInvoice(user.StoreId, invoice.Id, - new UpdateInvoiceRequest() + new UpdateInvoiceRequest { Metadata = metadataForUpdate }); }); invoice = await client.UpdateInvoice(user.StoreId, invoice.Id, - new UpdateInvoiceRequest() + new UpdateInvoiceRequest { Metadata = metadataForUpdate }); @@ -2288,13 +2288,12 @@ namespace BTCPayServer.Tests await client.UnarchiveInvoice(user.StoreId, invoice.Id); Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id)); - foreach (var marked in new[] { InvoiceStatus.Settled, InvoiceStatus.Invalid }) { var inv = await client.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() { Currency = "USD", Amount = 100 }); + new CreateInvoiceRequest { Currency = "USD", Amount = 100 }); await user.PayInvoice(inv.Id); - await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest() + await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest { Status = marked }); @@ -2322,13 +2321,12 @@ namespace BTCPayServer.Tests } } - newInvoice = await client.CreateInvoice(user.StoreId, - new CreateInvoiceRequest() + new CreateInvoiceRequest { Currency = "USD", Amount = 1, - Checkout = new CreateInvoiceRequest.CheckoutOptions() + Checkout = new CreateInvoiceRequest.CheckoutOptions { DefaultLanguage = "it-it ", RedirectURL = "http://toto.com/lol" diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 75b232f23..267bd0f9d 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -111,6 +111,7 @@ namespace BTCPayServer.Tests // Payment Request s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click(); s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click(); + Thread.Sleep(10000); s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123"); s.Driver.FindElement(By.Id("Amount")).SendKeys("700"); new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email"); @@ -461,6 +462,12 @@ namespace BTCPayServer.Tests using var s = CreateSeleniumTester(); await s.StartAsync(); s.RegisterNewUser(true); + s.CreateNewStore(); + + // Store Emails without server fallback + s.GoToStore(StoreNavPages.Emails); + s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click(); + Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource); // Server Emails s.Driver.Navigate().GoToUrl(s.Link("/server/emails")); @@ -470,12 +477,11 @@ namespace BTCPayServer.Tests s.FindAlertMessage(); } CanSetupEmailCore(s); - s.CreateNewStore(); - // Store Emails + // Store Emails with server fallback s.GoToStore(StoreNavPages.Emails); s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click(); - Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource); + Assert.Contains("Emails will be sent with the email settings of the server", s.Driver.PageSource); s.GoToStore(StoreNavPages.Emails); CanSetupEmailCore(s); @@ -485,10 +491,11 @@ namespace BTCPayServer.Tests Assert.Contains("There are no rules yet.", s.Driver.PageSource); Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource); Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource); + Assert.DoesNotContain("Emails will be sent with the email settings of the server", s.Driver.PageSource); s.Driver.FindElement(By.Id("CreateEmailRule")).Click(); var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger"))); - select.SelectByText("InvoiceSettled", true); + select.SelectByText("An invoice has been settled", true); s.Driver.FindElement(By.Id("Rules_0__To")).SendKeys("test@gmail.com"); s.Driver.FindElement(By.Id("Rules_0__CustomerEmail")).Click(); s.Driver.FindElement(By.Id("Rules_0__Subject")).SendKeys("Thanks!"); @@ -1418,13 +1425,6 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Name("update")).Click(); s.FindAlertMessage(); s.Driver.FindElement(By.LinkText("Modify")).Click(); - foreach (var value in Enum.GetValues(typeof(WebhookEventType))) - { - // Here we make sure we did not forget an event type in the list - // However, maybe some event should not appear here because not at the store level. - // Fix as needed. - Assert.Contains($"value=\"{value}\"", s.Driver.PageSource); - } // This one should be checked Assert.Contains("value=\"InvoiceProcessing\" checked", s.Driver.PageSource); @@ -3052,13 +3052,18 @@ retry: { s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click(); s.Driver.FindElement(By.CssSelector("#quick-fill .dropdown-menu .dropdown-item:first-child")).Click(); + s.Driver.FindElement(By.Id("Settings_Login")).Clear(); s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test@gmail.com"); s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit(); s.FindAlertMessage(); + s.Driver.FindElement(By.Id("Settings_Password")).Clear(); s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword"); + + s.Driver.FindElement(By.Id("Settings_From")).Clear(); s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname "); s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter); Assert.Contains("Configured", s.Driver.PageSource); + s.Driver.FindElement(By.Id("Settings_Login")).Clear(); s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test_fix@gmail.com"); s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter); Assert.Contains("Configured", s.Driver.PageSource); diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index e50219e4f..98555bff0 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -487,7 +487,7 @@ namespace BTCPayServer.Tests } public List WebhookEvents { get; set; } = new List(); - public TEvent AssertHasWebhookEvent(WebhookEventType eventType, Action assert) where TEvent : class + public TEvent AssertHasWebhookEvent(string eventType, Action assert) where TEvent : class { int retry = 0; retry: @@ -520,7 +520,7 @@ retry: } public async Task SetupWebhook() { - FakeServer server = new FakeServer(); + var server = new FakeServer(); await server.Start(); var client = await CreateClient(Policies.CanModifyStoreWebhooks); var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest() @@ -536,7 +536,7 @@ retry: { var inv = await BitPay.GetInvoiceAsync(invoiceId); var net = parent.ExplorerNode.Network; - this.parent.ExplorerNode.SendToAddress(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue); + await parent.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue); await TestUtils.EventuallyAsync(async () => { var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f5786d496..d4d6a0d5b 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1299,7 +1299,7 @@ namespace BTCPayServer.Tests using var tester = CreateServerTester(); await tester.StartAsync(); var user = tester.NewAccount(); - user.GrantAccess(); + await user.GrantAccessAsync(); user.RegisterDerivationScheme("BTC"); var rng = new Random(); @@ -1333,8 +1333,6 @@ namespace BTCPayServer.Tests var btcmethod = (await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id))[0]; var paid = btcSent; var invoiceAddress = BitcoinAddress.Create(btcmethod.Destination, cashCow.Network); - - var btc = new PaymentMethodId("BTC", PaymentTypes.BTCLike); var networkFee = (await tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id)) .GetPaymentMethods()[btc] @@ -1346,9 +1344,7 @@ namespace BTCPayServer.Tests networkFee = 0.0m; } - cashCow.SendToAddress(invoiceAddress, paid); - - + await cashCow.SendToAddressAsync(invoiceAddress, paid); await TestUtils.EventuallyAsync(async () => { try @@ -1952,11 +1948,11 @@ namespace BTCPayServer.Tests using var tester = CreateServerTester(); await tester.StartAsync(); var user = tester.NewAccount(); - user.GrantAccess(); + await user.GrantAccessAsync(); user.RegisterDerivationScheme("BTC"); await user.SetupWebhook(); - var invoice = user.BitPay.CreateInvoice( - new Invoice() + var invoice = await user.BitPay.CreateInvoiceAsync( + new Invoice { Price = 5000.0m, TaxIncluded = 1000.0m, @@ -2003,11 +1999,8 @@ namespace BTCPayServer.Tests Assert.Empty(user.BitPay.GetInvoices(invoice.InvoiceTime.UtcDateTime - TimeSpan.FromDays(5), invoice.InvoiceTime.DateTime - TimeSpan.FromDays(1))); - var firstPayment = Money.Coins(0.04m); - var txFee = Money.Zero; - var cashCow = tester.ExplorerNode; var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); @@ -2035,7 +2028,7 @@ namespace BTCPayServer.Tests secondPayment = localInvoice.BtcDue; }); - cashCow.SendToAddress(invoiceAddress, secondPayment); + await cashCow.SendToAddressAsync(invoiceAddress, secondPayment); TestUtils.Eventually(() => { @@ -2049,7 +2042,7 @@ namespace BTCPayServer.Tests Assert.False((bool)((JValue)localInvoice.ExceptionStatus).Value); }); - cashCow.Generate(1); //The user has medium speed settings, so 1 conf is enough to be confirmed + await cashCow.GenerateAsync(1); //The user has medium speed settings, so 1 conf is enough to be confirmed TestUtils.Eventually(() => { @@ -2057,7 +2050,7 @@ namespace BTCPayServer.Tests Assert.Equal("confirmed", localInvoice.Status); }); - cashCow.Generate(5); //Now should be complete + await cashCow.GenerateAsync(5); //Now should be complete TestUtils.Eventually(() => { @@ -2066,7 +2059,7 @@ namespace BTCPayServer.Tests Assert.NotEqual(0.0m, localInvoice.Rate); }); - invoice = user.BitPay.CreateInvoice(new Invoice() + invoice = await user.BitPay.CreateInvoiceAsync(new Invoice { Price = 5000.0m, Currency = "USD", @@ -2079,7 +2072,7 @@ namespace BTCPayServer.Tests }, Facade.Merchant); invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); - var txId = cashCow.SendToAddress(invoiceAddress, invoice.BtcDue + Money.Coins(1)); + var txId = await cashCow.SendToAddressAsync(invoiceAddress, invoice.BtcDue + Money.Coins(1)); TestUtils.Eventually(() => { @@ -2096,7 +2089,7 @@ namespace BTCPayServer.Tests Assert.Single(textSearchResult); }); - cashCow.Generate(1); + await cashCow.GenerateAsync(2); TestUtils.Eventually(() => { diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs index 7fc0e0110..61b69ee32 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs @@ -146,9 +146,7 @@ namespace BTCPayServer.Controllers.Greenfield return PaymentRequestNotFound(); } - var updatedPr = pr.First(); - updatedPr.Archived = true; - await _paymentRequestRepository.CreateOrUpdatePaymentRequest(updatedPr); + await _paymentRequestRepository.ArchivePaymentRequest(pr.First().Id); return Ok(); } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs index d53bf580a..f5f152f59 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs @@ -10,6 +10,7 @@ using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.HostedServices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Security; using BTCPayServer.Services.Stores; using Google.Apis.Auth.OAuth2; diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index 33918a5b8..9205adce3 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -12,6 +12,7 @@ using BTCPayServer.Services; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.HostedServices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Logging; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index dddc7ff4c..ae3a20ce8 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; -using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Form; using BTCPayServer.Client; using BTCPayServer.Client.Models; @@ -11,7 +10,6 @@ using BTCPayServer.Data; using BTCPayServer.Filters; using BTCPayServer.Forms; using BTCPayServer.Forms.Models; -using BTCPayServer.Models; using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.PaymentRequest; using BTCPayServer.Services; @@ -22,7 +20,6 @@ using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; using PaymentRequestData = BTCPayServer.Data.PaymentRequestData; using StoreData = BTCPayServer.Data.StoreData; @@ -115,7 +112,8 @@ namespace BTCPayServer.Controllers { return NotFound(); } - + + var storeBlob = store.GetStoreBlob(); var prInvoices = payReqId is null ? null : (await _PaymentRequestService.GetPaymentRequest(payReqId, GetUserId())).Invoices; var vm = new UpdatePaymentRequestViewModel(paymentRequest) { @@ -123,7 +121,9 @@ namespace BTCPayServer.Controllers AmountAndCurrencyEditable = payReqId is null || !prInvoices.Any() }; - vm.Currency ??= store.GetStoreBlob().DefaultCurrency; + vm.Currency ??= storeBlob.DefaultCurrency; + vm.HasEmailRules = storeBlob.EmailRules?.Any(rule => + rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase)); return View(nameof(EditPaymentRequest), vm); } @@ -162,10 +162,12 @@ namespace BTCPayServer.Controllers if (!ModelState.IsValid) { + var storeBlob = store.GetStoreBlob(); + viewModel.HasEmailRules = storeBlob.EmailRules?.Any(rule => + rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase)); return View(nameof(EditPaymentRequest), viewModel); } - blob.Title = viewModel.Title; blob.Email = viewModel.Email; blob.Description = viewModel.Description; @@ -413,13 +415,11 @@ namespace BTCPayServer.Controllers public async Task TogglePaymentRequestArchival(string payReqId) { var store = GetCurrentStore(); - var result = await EditPaymentRequest(store.Id, payReqId); - if (result is ViewResult viewResult) + + var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true); + if(result is not null) { - var model = (UpdatePaymentRequestViewModel)viewResult.Model; - model.Archived = !model.Archived; - await EditPaymentRequest(payReqId, model); - TempData[WellKnownTempData.SuccessMessage] = model.Archived + TempData[WellKnownTempData.SuccessMessage] = result.Value ? "The payment request has been archived and will no longer appear in the payment request list by default again." : "The payment request has been unarchived and will appear in the payment request list by default."; return RedirectToAction("GetPaymentRequests", new { storeId = store.Id }); diff --git a/BTCPayServer/Controllers/UIStoresController.Email.cs b/BTCPayServer/Controllers/UIStoresController.Email.cs index b7f7e65c6..acbece5da 100644 --- a/BTCPayServer/Controllers/UIStoresController.Email.cs +++ b/BTCPayServer/Controllers/UIStoresController.Email.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; +using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; @@ -19,20 +20,25 @@ namespace BTCPayServer.Controllers public partial class UIStoresController { [HttpGet("{storeId}/emails")] - public IActionResult StoreEmails(string storeId) + public async Task StoreEmails(string storeId) { var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var blob = store.GetStoreBlob(); - var data = blob.EmailSettings; - if (data?.IsComplete() is not true) + var storeSetupComplete = blob.EmailSettings?.IsComplete() is true; + if (!storeSetupComplete && !TempData.HasStatusMessage()) { + var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender; + var hasServerFallback = await IsSetupComplete(emailSender?.FallbackSender); + var message = hasServerFallback + ? "Emails will be sent with the email settings of the server" + : "You need to configure email settings before this feature works"; TempData.SetStatusMessageModel(new StatusMessageModel { - Severity = StatusMessageModel.StatusSeverity.Warning, - Html = $"You need to configure email settings before this feature works. Configure now." + Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : StatusMessageModel.StatusSeverity.Warning, + Html = $"{message}. Configure store email settings." }); } @@ -44,17 +50,17 @@ namespace BTCPayServer.Controllers public async Task StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command) { vm.Rules ??= new List(); - int index = 0; + int commandIndex = 0; var indSep = command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase); if (indSep > 0) { var item = command[(indSep + 1)..]; - index = int.Parse(item, CultureInfo.InvariantCulture); + commandIndex = int.Parse(item, CultureInfo.InvariantCulture); } if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase)) { - vm.Rules.RemoveAt(index); + vm.Rules.RemoveAt(commandIndex); } else if (command == "add") { @@ -63,7 +69,7 @@ namespace BTCPayServer.Controllers return View(vm); } - for (var i = 0; index < vm.Rules.Count; index++) + for (var i = 0; i < vm.Rules.Count; i++) { var rule = vm.Rules[i]; if (!rule.CustomerEmail && string.IsNullOrEmpty(rule.To)) @@ -79,41 +85,50 @@ namespace BTCPayServer.Controllers if (store == null) return NotFound(); + + string message = ""; + + // update rules var blob = store.GetStoreBlob(); + blob.EmailRules = vm.Rules; + if (store.SetStoreBlob(blob)) + { + await _Repo.UpdateStore(store); + message += "Store email rules saved. "; + } if (command.StartsWith("test", StringComparison.InvariantCultureIgnoreCase)) { - var rule = vm.Rules[index]; try { - var emailSettings = blob.EmailSettings; - using var client = await emailSettings.CreateSmtpClient(); - var message = emailSettings.CreateMailMessage(MailboxAddress.Parse(rule.To), "(test) " + rule.Subject, rule.Body, true); - await client.SendAsync(message); - await client.DisconnectAsync(true); - TempData[WellKnownTempData.SuccessMessage] = $"Rule email saved and sent to {rule.To}. Please verify you received it."; - - blob.EmailRules = vm.Rules; - store.SetStoreBlob(blob); - await _Repo.UpdateStore(store); + var rule = vm.Rules[commandIndex]; + var emailSender = await _emailSenderFactory.GetEmailSender(store.Id); + if (await IsSetupComplete(emailSender)) + { + emailSender.SendEmail(MailboxAddress.Parse(rule.To), $"({store.StoreName} test) {rule.Subject}", rule.Body); + message += $"Test email sent to {rule.To} — please verify you received it."; + } + else + { + message += "Complete the email setup to send test emails."; + } } catch (Exception ex) { - TempData[WellKnownTempData.ErrorMessage] = "Error: " + ex.Message; + TempData[WellKnownTempData.ErrorMessage] = message + "Error sending test email: " + ex.Message; + return RedirectToAction("StoreEmails", new { storeId }); } } - else + + if (!string.IsNullOrEmpty(message)) { - // UPDATE - blob.EmailRules = vm.Rules; - store.SetStoreBlob(blob); - await _Repo.UpdateStore(store); TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Success, - Message = "Store email rules saved" + Message = message }); } + return RedirectToAction("StoreEmails", new { storeId }); } @@ -125,7 +140,7 @@ namespace BTCPayServer.Controllers public class StoreEmailRule { [Required] - public WebhookEventType Trigger { get; set; } + public string Trigger { get; set; } public bool CustomerEmail { get; set; } @@ -209,5 +224,10 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(StoreEmailSettings), new { storeId }); } } + + private static async Task IsSetupComplete(IEmailSender emailSender) + { + return emailSender is not null && (await emailSender.GetEmailSettings())?.IsComplete() == true; + } } } diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index e0b2af5c5..2e9775c60 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -13,6 +13,7 @@ using BTCPayServer.Client; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.HostedServices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; @@ -22,6 +23,7 @@ using BTCPayServer.Security.Bitpay; using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Mails; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; @@ -68,7 +70,8 @@ namespace BTCPayServer.Controllers IOptions lightningNetworkOptions, IOptions externalServiceOptions, IHtmlHelper html, - LightningClientFactoryService lightningClientFactoryService) + LightningClientFactoryService lightningClientFactoryService, + EmailSenderFactory emailSenderFactory) { _RateFactory = rateFactory; _Repo = repo; @@ -93,6 +96,7 @@ namespace BTCPayServer.Controllers _BTCPayEnv = btcpayEnv; _externalServiceOptions = externalServiceOptions; _lightningClientFactoryService = lightningClientFactoryService; + _emailSenderFactory = emailSenderFactory; Html = html; } @@ -116,6 +120,7 @@ namespace BTCPayServer.Controllers private readonly EventAggregator _EventAggregator; private readonly IOptions _externalServiceOptions; private readonly LightningClientFactoryService _lightningClientFactoryService; + private readonly EmailSenderFactory _emailSenderFactory; public string? GeneratedPairingCode { get; set; } public WebhookSender WebhookNotificationManager { get; } diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index ac637dd14..fbb019182 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -37,6 +37,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler private readonly ApplicationDbContextFactory _dbContextFactory; private readonly NotificationSender _notificationSender; private readonly Logs Logs; + private readonly EventAggregator _eventAggregator; private readonly TransactionLinkProviders _transactionLinkProviders; public WalletRepository WalletRepository { get; } @@ -48,6 +49,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler ApplicationDbContextFactory dbContextFactory, NotificationSender notificationSender, Logs logs, + EventAggregator eventAggregator, TransactionLinkProviders transactionLinkProviders) { _btcPayNetworkProvider = btcPayNetworkProvider; @@ -57,6 +59,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler _dbContextFactory = dbContextFactory; _notificationSender = notificationSender; this.Logs = logs; + _eventAggregator = eventAggregator; _transactionLinkProviders = transactionLinkProviders; } @@ -329,7 +332,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler .Include(p => p.PullPaymentData) .Where(p => p.State == PayoutState.InProgress) .ToListAsync(); - + List updatedPayouts = new List(); foreach (var payout in payouts) { var proof = ParseProof(payout) as PayoutTransactionOnChainBlob; @@ -350,6 +353,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler { payout.State = PayoutState.Completed; proof.TransactionId = tx.TransactionHash; + updatedPayouts.Add(payout); break; } else @@ -364,6 +368,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler { payout.State = PayoutState.InProgress; proof.TransactionId = tx.TransactionHash; + updatedPayouts.Add(payout); continue; } } @@ -376,6 +381,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler if (proof.Candidates.Count == 0) { + if (payout.State != PayoutState.AwaitingPayment) + { + updatedPayouts.Add(payout); + } payout.State = PayoutState.AwaitingPayment; } else if (proof.TransactionId is null) @@ -389,6 +398,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler } await ctx.SaveChangesAsync(); + foreach (PayoutData payoutData in updatedPayouts) + { + _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated,payoutData)); + } } catch (Exception ex) { @@ -466,9 +479,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler proof.TransactionId ??= txId; SetProofBlob(payout, proof); - - await ctx.SaveChangesAsync(); + _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated,payout)); } catch (Exception ex) { diff --git a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs index 7339fd98a..fa4c005c4 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs @@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Configuration; +using BTCPayServer.HostedServices; using BTCPayServer.Lightning; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; @@ -35,6 +36,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike private readonly LightningClientFactoryService _lightningClientFactoryService; private readonly IOptions _options; private readonly IAuthorizationService _authorizationService; + private readonly EventAggregator _eventAggregator; private readonly StoreRepository _storeRepository; public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory, @@ -44,7 +46,9 @@ namespace BTCPayServer.Data.Payouts.LightningLike BTCPayNetworkProvider btcPayNetworkProvider, StoreRepository storeRepository, LightningClientFactoryService lightningClientFactoryService, - IOptions options, IAuthorizationService authorizationService) + IOptions options, + IAuthorizationService authorizationService, + EventAggregator eventAggregator) { _applicationDbContextFactory = applicationDbContextFactory; _userManager = userManager; @@ -55,6 +59,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike _options = options; _storeRepository = storeRepository; _authorizationService = authorizationService; + _eventAggregator = eventAggregator; } private async Task> GetPayouts(ApplicationDbContext dbContext, PaymentMethodId pmi, @@ -214,6 +219,16 @@ namespace BTCPayServer.Data.Payouts.LightningLike } await ctx.SaveChangesAsync(); + foreach (var payoutG in payouts) + { + foreach (PayoutData payout in payoutG) + { + if (payout.State != PayoutState.AwaitingPayment) + { + _eventAggregator.Publish(new PayoutEvent(null, payout)); + } + } + } return View("LightningPayoutResult", results); } public static async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData, diff --git a/BTCPayServer/Data/WebhookDataExtensions.cs b/BTCPayServer/Data/WebhookDataExtensions.cs index 406e890ab..31fe898e1 100644 --- a/BTCPayServer/Data/WebhookDataExtensions.cs +++ b/BTCPayServer/Data/WebhookDataExtensions.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using BTCPayServer.Client.Models; +using BTCPayServer.HostedServices.Webhooks; using Microsoft.CodeAnalysis.CSharp.Syntax; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using SshNet.Security.Cryptography; namespace BTCPayServer.Data { @@ -16,9 +16,8 @@ namespace BTCPayServer.Data { public bool Everything { get; set; } - [JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] - public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty(); - public bool Match(WebhookEventType evt) + public string[] SpecificEvents { get; set; } = Array.Empty(); + public bool Match(string evt) { return Everything || SpecificEvents.Contains(evt); } @@ -47,7 +46,7 @@ namespace BTCPayServer.Data } public T ReadRequestAs() { - return JsonConvert.DeserializeObject(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings); + return JsonConvert.DeserializeObject(UTF8Encoding.UTF8.GetString(Request), WebhookSender.DefaultSerializerSettings); } public bool IsPruned() @@ -78,14 +77,14 @@ namespace BTCPayServer.Data if (webhook.Blob is null) return null; else - return JsonConvert.DeserializeObject(webhook.Blob, HostedServices.WebhookSender.DefaultSerializerSettings); + return JsonConvert.DeserializeObject(webhook.Blob, WebhookSender.DefaultSerializerSettings); } public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob) { if (blob is null) webhook.Blob = null; else - webhook.Blob = JsonConvert.SerializeObject(blob, HostedServices.WebhookSender.DefaultSerializerSettings); + webhook.Blob = JsonConvert.SerializeObject(blob, WebhookSender.DefaultSerializerSettings); } } } diff --git a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs b/BTCPayServer/HostedServices/PluginUpdateFetcher.cs index ac633a7dd..99aafcee2 100644 --- a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs +++ b/BTCPayServer/HostedServices/PluginUpdateFetcher.cs @@ -22,17 +22,8 @@ namespace BTCPayServer.HostedServices { private const string TYPE = "pluginupdate"; - internal class Handler : NotificationHandler + internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options) : NotificationHandler { - private readonly LinkGenerator _linkGenerator; - private readonly BTCPayServerOptions _options; - - public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options) - { - _linkGenerator = linkGenerator; - _options = options; - } - public override string NotificationType => TYPE; public override (string identifier, string name)[] Meta @@ -48,9 +39,9 @@ namespace BTCPayServer.HostedServices vm.Identifier = notification.Identifier; vm.Type = notification.NotificationType; vm.Body = $"New {notification.Name} plugin version {notification.Version} released!"; - vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins), + vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins), "UIServer", - new {plugin = notification.PluginIdentifier}, _options.RootPath); + new {plugin = notification.PluginIdentifier}, options.RootPath); } } @@ -79,34 +70,19 @@ namespace BTCPayServer.HostedServices public Dictionary LastVersions { get; set; } } - public class PluginUpdateFetcher : IPeriodicTask + public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService) + : IPeriodicTask { - public PluginUpdateFetcher( - SettingsRepository settingsRepository, - ILogger logger, NotificationSender notificationSender, PluginService pluginService) - { - _settingsRepository = settingsRepository; - _logger = logger; - _notificationSender = notificationSender; - _pluginService = pluginService; - } - - private readonly SettingsRepository _settingsRepository; - private readonly ILogger _logger; - private readonly NotificationSender _notificationSender; - private readonly PluginService _pluginService; - - public async Task Do(CancellationToken cancellationToken) { - var dh = await _settingsRepository.GetSettingAsync() ?? + var dh = await settingsRepository.GetSettingAsync() ?? new PluginVersionCheckerDataHolder(); dh.LastVersions ??= new Dictionary(); - var disabledPlugins = _pluginService.GetDisabledPlugins(); + var disabledPlugins = pluginService.GetDisabledPlugins(); var installedPlugins = - _pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); - var remotePlugins = await _pluginService.GetRemotePlugins(); + pluginService.LoadedPlugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); + var remotePlugins = await pluginService.GetRemotePlugins(); var remotePluginsList = remotePlugins .Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name)) .ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); @@ -128,10 +104,10 @@ namespace BTCPayServer.HostedServices foreach (string pluginUpdate in notify) { var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate); - await _notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin)); + await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin)); } - await _settingsRepository.UpdateSetting(dh); + await settingsRepository.UpdateSetting(dh); } } } diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 5bbed8c25..137a2497e 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -493,6 +493,7 @@ namespace BTCPayServer.HostedServices } payout.State = req.Request.State; await ctx.SaveChangesAsync(); + _eventAggregator.Publish(new PayoutEvent(null, payout)); req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok); } catch (Exception ex) @@ -711,6 +712,11 @@ namespace BTCPayServer.HostedServices } await ctx.SaveChangesAsync(); + foreach (var keyValuePair in result.Where(pair => pair.Value == MarkPayoutRequest.PayoutPaidResult.Ok)) + { + var payout = payouts.First(p => p.Id == keyValuePair.Key); + _eventAggregator.Publish(new PayoutEvent(null, payout)); + } cancel.Completion.TrySetResult(result); } catch (Exception ex) @@ -929,13 +935,13 @@ namespace BTCPayServer.HostedServices public JObject Metadata { get; set; } } - public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout) + public record PayoutEvent(PayoutEvent.PayoutEventType? Type, PayoutData Payout) { public enum PayoutEventType { Created, - Approved + Approved, + Updated } - } } diff --git a/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs b/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs index b40955eb3..903adb4d9 100644 --- a/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs +++ b/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs @@ -1,20 +1,14 @@ using System; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; -using BTCPayServer.Controllers.Greenfield; using BTCPayServer.Data; -using BTCPayServer.Events; -using BTCPayServer.Services; -using BTCPayServer.Services.Invoices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Services.Mails; -using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using InvoiceData = BTCPayServer.Client.Models.InvoiceData; namespace BTCPayServer.HostedServices; @@ -22,82 +16,75 @@ public class StoreEmailRuleProcessorSender : EventHostedServiceBase { private readonly StoreRepository _storeRepository; private readonly EmailSenderFactory _emailSenderFactory; - private readonly LinkGenerator _linkGenerator; - private readonly CurrencyNameTable _currencyNameTable; - public StoreEmailRuleProcessorSender(StoreRepository storeRepository, EventAggregator eventAggregator, ILogger logger, - EmailSenderFactory emailSenderFactory, - LinkGenerator linkGenerator, - CurrencyNameTable currencyNameTable) : base( + EmailSenderFactory emailSenderFactory) : base( eventAggregator, logger) { _storeRepository = storeRepository; _emailSenderFactory = emailSenderFactory; - _linkGenerator = linkGenerator; - _currencyNameTable = currencyNameTable; } protected override void SubscribeToEvents() { - Subscribe(); + Subscribe(); } protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) { - if (evt is InvoiceEvent invoiceEvent) + if (evt is WebhookSender.WebhookDeliveryRequest webhookDeliveryRequest) { - var type = WebhookSender.GetWebhookEvent(invoiceEvent); + var type = webhookDeliveryRequest.WebhookEvent.Type; if (type is null) { return; } - var store = await _storeRepository.FindStore(invoiceEvent.Invoice.StoreId); - + if (webhookDeliveryRequest.WebhookEvent is not StoreWebhookEvent storeWebhookEvent || storeWebhookEvent.StoreId is null) + { + return; + } + var store = await _storeRepository.FindStore(storeWebhookEvent.StoreId); + if (store is null) + { + return; + } var blob = store.GetStoreBlob(); if (blob.EmailRules?.Any() is true) { - var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type.Type).ToList(); + var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type).ToList(); if (actionableRules.Any()) { - var sender = await _emailSenderFactory.GetEmailSender(invoiceEvent.Invoice.StoreId); + var sender = await _emailSenderFactory.GetEmailSender(storeWebhookEvent.StoreId); foreach (UIStoresController.StoreEmailRule actionableRule in actionableRules) { - var recipients = (actionableRule.To?.Split(",", StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) + + + 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()) .Select(o => { MailboxAddressValidator.TryParse(o, out var mb); return mb; }) .Where(o => o != null) - .ToList(); - if (actionableRule.CustomerEmail && - MailboxAddressValidator.TryParse(invoiceEvent.Invoice.Metadata.BuyerEmail, out var bmb)) - { - recipients.Add(bmb); - } - var i = GreenfieldInvoiceController.ToModel(invoiceEvent.Invoice, _linkGenerator, null); - sender.SendEmail(recipients.ToArray(), null, null, Interpolator(actionableRule.Subject, i), - Interpolator(actionableRule.Body, i)); + .ToArray(); + + if(recipients.Length == 0) + continue; + + sender.SendEmail(recipients.ToArray(), null, null, request.Subject, request.Body); } } } } } - private string Interpolator(string str, InvoiceData i) - { - //TODO: we should switch to https://dotnetfiddle.net/MoqJFk later - return str.Replace("{Invoice.Id}", i.Id) - .Replace("{Invoice.StoreId}", i.StoreId) - .Replace("{Invoice.Price}", - decimal.Round(i.Amount, _currencyNameTable.GetCurrencyData(i.Currency, true).Divisibility, - MidpointRounding.ToEven).ToString(CultureInfo.InvariantCulture)) - .Replace("{Invoice.Currency}", i.Currency) - .Replace("{Invoice.Status}", i.Status.ToString()) - .Replace("{Invoice.AdditionalStatus}", i.AdditionalStatus.ToString()) - .Replace("{Invoice.OrderId}", i.Metadata.ToObject().OrderId); - } } diff --git a/BTCPayServer/HostedServices/WebhookSender.cs b/BTCPayServer/HostedServices/WebhookSender.cs deleted file mode 100644 index 28657bfdd..000000000 --- a/BTCPayServer/HostedServices/WebhookSender.cs +++ /dev/null @@ -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 -{ - /// - /// This class send webhook notifications - /// It also make sure the events sent to a webhook are sent in order to the webhook - /// - 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(); - } - - public async Task Redeliver(string deliveryId) - { - var deliveryRequest = await CreateRedeliveryRequest(deliveryId); - if (deliveryRequest is null) - return null; - EnqueueDelivery(deliveryRequest); - return deliveryRequest.Delivery.Id; - } - - private async Task 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(); - 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 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 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 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; - } - } -} diff --git a/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs new file mode 100644 index 000000000..ed795aff1 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.HostedServices.Webhooks; + +public interface IWebhookProvider +{ + public Dictionary GetSupportedWebhookTypes(); + + public WebhookEvent CreateTestEvent(string type, params object[] args); +} diff --git a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs new file mode 100644 index 000000000..a7f818b52 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs @@ -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 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; + } + +} diff --git a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs new file mode 100644 index 000000000..3cbe2c407 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs @@ -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 +{ + public InvoiceWebhookProvider(WebhookSender webhookSender, EventAggregator eventAggregator, + ILogger logger) : base( + eventAggregator, logger, webhookSender) + { + } + + public override Dictionary GetSupportedWebhookTypes() + { + return new Dictionary + { + {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; + } + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs new file mode 100644 index 000000000..61ac47e94 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs @@ -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 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; + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs new file mode 100644 index 000000000..191f5315e --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs @@ -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 +{ + public PaymentRequestWebhookProvider(EventAggregator eventAggregator, ILogger logger, WebhookSender webhookSender) : base(eventAggregator, logger, webhookSender) + { + } + + public override Dictionary GetSupportedWebhookTypes() + { + return new Dictionary() + { + {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 ); + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs new file mode 100644 index 000000000..b9213aa50 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs @@ -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 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; + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs new file mode 100644 index 000000000..4a37064f0 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs @@ -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 logger, + WebhookSender webhookSender, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings) + : WebhookProvider(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 GetSupportedWebhookTypes() + { + return new Dictionary() + { + {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 + }; + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs b/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs new file mode 100644 index 000000000..4112c54e8 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs @@ -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(); + services.AddSingleton(o => o.GetRequiredService()); + services.AddHostedService(o => o.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); + services.AddHostedService(o => o.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); + services.AddHostedService(o => o.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); + services.AddScheduledTask(TimeSpan.FromHours(6.0)); + services.AddHttpClient(WebhookSender.OnionNamedClient) + .ConfigurePrimaryHttpMessageHandler(); + services.AddHttpClient(WebhookSender.LoopbackNamedClient) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + return services; + } +} diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs new file mode 100644 index 000000000..4976cb428 --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs @@ -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(EventAggregator eventAggregator, ILogger logger, WebhookSender webhookSender) + : EventHostedServiceBase(eventAggregator, logger), IWebhookProvider +{ + public abstract Dictionary 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(); + 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); + } + +} diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs b/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs new file mode 100644 index 000000000..94b6d405d --- /dev/null +++ b/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs @@ -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 +{ + /// + /// This class sends webhook notifications + /// It also makes sure the events sent to a webhook are sent in order to the webhook + /// + 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 _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 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 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 Redeliver(string deliveryId) + { + var deliveryRequest = await CreateRedeliveryRequest(deliveryId); + if (deliveryRequest is null) + return null; + EnqueueDelivery(deliveryRequest); + return deliveryRequest.Delivery.Id; + } + + private async Task 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(); + 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() + .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 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 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 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 GetWebhooks(string invoiceStoreId, string? webhookEventType) + { + return (await StoreRepository.GetWebhooks(invoiceStoreId)).Where(data => webhookEventType is null || data.GetBlob().ShouldDeliver(webhookEventType)).ToArray(); + } + + public async Task GetEmailRules(string storeId, + string type) + { + return ( await StoreRepository.FindStore(storeId))?.GetStoreBlob().EmailRules?.Where(rule => rule.Trigger ==type).ToArray() ?? Array.Empty(); + } + + public Dictionary GetSupportedWebhookTypes() + { + return _serviceProvider.GetServices() + .SelectMany(provider => provider.GetSupportedWebhookTypes()).ToDictionary(pair => pair.Key, pair => pair.Value); + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index a9def9b71..c1d0a9144 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -18,6 +18,7 @@ using BTCPayServer.Data; using BTCPayServer.Data.Payouts.LightningLike; using BTCPayServer.Forms; using BTCPayServer.HostedServices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Lightning; using BTCPayServer.Lightning.Charge; using BTCPayServer.Lightning.CLightning; @@ -374,11 +375,8 @@ namespace BTCPayServer.Hosting services.AddSingleton(o => o.GetRequiredService()); services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(o => o.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); - services.AddScheduledTask(TimeSpan.FromHours(6.0)); services.AddScheduledTask(TimeSpan.FromDays(1)); services.AddScheduledTask(TimeSpan.FromDays(1)); @@ -386,17 +384,7 @@ namespace BTCPayServer.Hosting services.AddReportProvider(); services.AddReportProvider(); services.AddReportProvider(); - - services.AddHttpClient(WebhookSender.OnionNamedClient) - .ConfigurePrimaryHttpMessageHandler(); - services.AddHttpClient(WebhookSender.LoopbackNamedClient) - .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }); - - + services.AddWebhooks(); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs index 9b4e424f3..703c73427 100644 --- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -98,6 +98,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels public Dictionary FormResponse { get; set; } public bool AmountAndCurrencyEditable { get; set; } = true; + public bool? HasEmailRules { get; set; } } public class ViewPaymentRequestViewModel diff --git a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs index 69a6ab17e..467848687 100644 --- a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs @@ -30,7 +30,7 @@ namespace BTCPayServer.Models.StoreViewModels } public string Id { get; set; } public DateTimeOffset Time { get; set; } - public WebhookEventType Type { get; private set; } + public string Type { get; private set; } public bool Pruned { get; set; } public string WebhookId { get; set; } public bool Success { get; set; } @@ -57,7 +57,7 @@ namespace BTCPayServer.Models.StoreViewModels public bool Active { get; set; } public bool AutomaticRedelivery { get; set; } public bool Everything { get; set; } - public WebhookEventType[] Events { get; set; } = Array.Empty(); + public string[] Events { get; set; } = Array.Empty(); [Uri] [Required] public string PayloadUrl { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs index d5fe1c284..d1d05148a 100644 --- a/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs @@ -4,6 +4,6 @@ namespace BTCPayServer.Models.StoreViewModels { public class TestWebhookViewModel { - public WebhookEventType Type { get; set; } + public string Type { get; set; } } } diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index fcfbc63ff..61e610daa 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -16,8 +16,8 @@ namespace BTCPayServer.PaymentRequest { public class PaymentRequestService { - private readonly PaymentRequestRepository _PaymentRequestRepository; - private readonly BTCPayNetworkProvider _BtcPayNetworkProvider; + private readonly PaymentRequestRepository _paymentRequestRepository; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; private readonly InvoiceRepository _invoiceRepository; private readonly CurrencyNameTable _currencies; private readonly TransactionLinkProviders _transactionLinkProviders; @@ -31,8 +31,8 @@ namespace BTCPayServer.PaymentRequest CurrencyNameTable currencies, TransactionLinkProviders transactionLinkProviders) { - _PaymentRequestRepository = paymentRequestRepository; - _BtcPayNetworkProvider = btcPayNetworkProvider; + _paymentRequestRepository = paymentRequestRepository; + _btcPayNetworkProvider = btcPayNetworkProvider; _invoiceRepository = invoiceRepository; _currencies = currencies; _transactionLinkProviders = transactionLinkProviders; @@ -41,7 +41,7 @@ namespace BTCPayServer.PaymentRequest public async Task UpdatePaymentRequestStateIfNeeded(string id) { - var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null); + var pr = await _paymentRequestRepository.FindPaymentRequest(id, null); await UpdatePaymentRequestStateIfNeeded(pr); } @@ -61,7 +61,7 @@ namespace BTCPayServer.PaymentRequest if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Expired) { - var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id); + var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id); var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); var allSettled = contributions.All(i => i.Value.States.All(s => s.IsSettled())); var isPaid = contributions.TotalCurrency >= blob.Amount; @@ -81,13 +81,13 @@ namespace BTCPayServer.PaymentRequest if (currentStatus != pr.Status) { pr.Status = currentStatus; - await _PaymentRequestRepository.UpdatePaymentRequestStatus(pr.Id, currentStatus); + await _paymentRequestRepository.UpdatePaymentRequestStatus(pr.Id, currentStatus); } } public async Task GetPaymentRequest(string id, string userId = null) { - var pr = await _PaymentRequestRepository.FindPaymentRequest(id, userId); + var pr = await _paymentRequestRepository.FindPaymentRequest(id, userId); if (pr == null) { return null; @@ -95,7 +95,7 @@ namespace BTCPayServer.PaymentRequest var blob = pr.GetBlob(); - var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id); + var invoices = await _paymentRequestRepository.GetInvoicesForPaymentRequest(id); var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); var amountDue = blob.Amount - paymentStats.TotalCurrency; var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime) diff --git a/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs index 16c194e74..620271c92 100644 --- a/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs +++ b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs @@ -41,7 +41,7 @@ public abstract class BaseAutomatedPayoutProcessor : BaseAsyncService where T protected readonly BTCPayNetworkProvider _btcPayNetworkProvider; protected readonly PaymentMethodId PaymentMethodId; private readonly IPluginHookService _pluginHookService; - private readonly EventAggregator _eventAggregator; + protected readonly EventAggregator _eventAggregator; protected BaseAutomatedPayoutProcessor( ILoggerFactory logger, @@ -115,7 +115,13 @@ public abstract class BaseAutomatedPayoutProcessor : BaseAsyncService where T Logs.PayServer.LogInformation( $"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})"); await Process(paymentMethod, payouts); + await context.SaveChangesAsync(); + + foreach (var payoutData in payouts.Where(payoutData => payoutData.State != PayoutState.AwaitingPayment)) + { + _eventAggregator.Publish(new PayoutEvent(null, payoutData)); + } } // Allow plugins do to something after automatic payout processing diff --git a/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs index 4d2744695..eb4049895 100644 --- a/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs +++ b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs @@ -27,7 +27,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain private readonly BTCPayWalletProvider _btcPayWalletProvider; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler; - private readonly EventAggregator _eventAggregator; public OnChainAutomatedPayoutProcessor( ApplicationDbContextFactory applicationDbContextFactory, @@ -51,7 +50,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain _btcPayWalletProvider = btcPayWalletProvider; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _bitcoinLikePayoutHandler = bitcoinLikePayoutHandler; - _eventAggregator = eventAggregator; WalletRepository = walletRepository; FeeProvider = feeProviderFactory.CreateFeeProvider(_btcPayNetworkProvider.GetNetwork(PaymentMethodId.CryptoCode)); } diff --git a/BTCPayServer/Services/Mails/IEmailSender.cs b/BTCPayServer/Services/Mails/IEmailSender.cs index ce0b9936e..218de362d 100644 --- a/BTCPayServer/Services/Mails/IEmailSender.cs +++ b/BTCPayServer/Services/Mails/IEmailSender.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using MimeKit; namespace BTCPayServer.Services.Mails @@ -6,5 +7,6 @@ namespace BTCPayServer.Services.Mails { void SendEmail(MailboxAddress email, string subject, string message); void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message); + Task GetEmailSettings(); } } diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs index e3a8633f2..588689753 100644 --- a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs +++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs @@ -9,24 +9,41 @@ using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Services.PaymentRequests { + public record PaymentRequestEvent + { + public const string Created = nameof(Created); + public const string Updated = nameof(Updated); + public const string Archived = nameof(Archived); + public const string StatusChanged = nameof(StatusChanged); + public PaymentRequestData Data { get; set; } + public string Type { get; set; } + + + } + public class PaymentRequestRepository { private readonly ApplicationDbContextFactory _ContextFactory; private readonly InvoiceRepository _InvoiceRepository; + private readonly EventAggregator _eventAggregator; - public PaymentRequestRepository(ApplicationDbContextFactory contextFactory, InvoiceRepository invoiceRepository) + public PaymentRequestRepository(ApplicationDbContextFactory contextFactory, + InvoiceRepository invoiceRepository, EventAggregator eventAggregator) { _ContextFactory = contextFactory; _InvoiceRepository = invoiceRepository; + _eventAggregator = eventAggregator; } public async Task CreateOrUpdatePaymentRequest(PaymentRequestData entity) { await using var context = _ContextFactory.CreateContext(); + var added = false; if (string.IsNullOrEmpty(entity.Id)) { entity.Id = Guid.NewGuid().ToString(); await context.PaymentRequests.AddAsync(entity); + added = true; } else { @@ -34,9 +51,37 @@ namespace BTCPayServer.Services.PaymentRequests } await context.SaveChangesAsync(); + _eventAggregator.Publish(new PaymentRequestEvent() + { + Data = entity, + Type = added ? PaymentRequestEvent.Created : PaymentRequestEvent.Updated + }); return entity; } + public async Task 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 FindPaymentRequest(string id, string userId, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(id)) @@ -44,7 +89,7 @@ namespace BTCPayServer.Services.PaymentRequests return null; } - using var context = _ContextFactory.CreateContext(); + await using var context = _ContextFactory.CreateContext(); var result = await context.PaymentRequests.Include(x => x.StoreData) .Where(data => string.IsNullOrEmpty(userId) || @@ -53,27 +98,23 @@ namespace BTCPayServer.Services.PaymentRequests return result; } - public async Task IsPaymentRequestAdmin(string paymentRequestId, string userId) - { - if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(paymentRequestId)) - { - return false; - } - using var context = _ContextFactory.CreateContext(); - return await context.PaymentRequests.Include(x => x.StoreData) - .AnyAsync(data => - data.Id == paymentRequestId && - (data.StoreData != null && data.StoreData.UserStores.Any(u => u.ApplicationUserId == userId))); - } - public async Task UpdatePaymentRequestStatus(string paymentRequestId, Client.Models.PaymentRequestData.PaymentRequestStatus status, CancellationToken cancellationToken = default) { - using var context = _ContextFactory.CreateContext(); - var invoiceData = await context.FindAsync(paymentRequestId); - if (invoiceData == null) + await using var context = _ContextFactory.CreateContext(); + var paymentRequestData = await context.FindAsync(paymentRequestId); + if (paymentRequestData == null) return; - invoiceData.Status = status; + if( paymentRequestData.Status == status) + return; + paymentRequestData.Status = status; + await context.SaveChangesAsync(cancellationToken); + + _eventAggregator.Publish(new PaymentRequestEvent() + { + Data = paymentRequestData, + Type = PaymentRequestEvent.StatusChanged + }); } public async Task FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default) diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index 43249bf42..533fb1d2b 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -24,7 +24,7 @@ namespace BTCPayServer.Services.Stores public JsonSerializerSettings SerializerSettings { get; } - public ApplicationDbContext CreateDbContext() + protected ApplicationDbContext CreateDbContext() { return _ContextFactory.CreateContext(); } diff --git a/BTCPayServer/Views/Shared/_StatusMessage.cshtml b/BTCPayServer/Views/Shared/_StatusMessage.cshtml index b1f3af0ff..d7fe365e4 100644 --- a/BTCPayServer/Views/Shared/_StatusMessage.cshtml +++ b/BTCPayServer/Views/Shared/_StatusMessage.cshtml @@ -1,4 +1,3 @@ -@using BTCPayServer.Abstractions.Extensions @{ var parsedModel = TempData.GetStatusMessageModel(); } diff --git a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml index 8b004ba5c..0e6ab8bc6 100644 --- a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml +++ b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml @@ -7,7 +7,6 @@ @inject FormDataService FormDataService @model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel @{ - var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId); ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id); @@ -85,7 +84,14 @@ -
The recipient's email. This will send notification mails to the recipient, as configured by the email rules, and include the email in the invoice export data.
+
+ This will send notification mails to the recipient, as configured by the + email rules. + @if (Model.HasEmailRules is not true) + { +
No payment request related email rules have been configured for this store.
+ } +
diff --git a/BTCPayServer/Views/UIStores/ModifyWebhook.cshtml b/BTCPayServer/Views/UIStores/ModifyWebhook.cshtml index 792267a52..aa558df91 100644 --- a/BTCPayServer/Views/UIStores/ModifyWebhook.cshtml +++ b/BTCPayServer/Views/UIStores/ModifyWebhook.cshtml @@ -1,5 +1,7 @@ @model EditWebhookViewModel @using BTCPayServer.Client.Models; +@using BTCPayServer.HostedServices.Webhooks +@inject WebhookSender WebhookSender @{ Layout = "../Shared/_NavLayout.cshtml"; ViewData.SetActivePage(StoreNavPages.Webhooks, "Webhook Settings", Context.GetStoreData().Id); @@ -55,20 +57,11 @@
- @foreach (var evt in new[] - { - ("A new invoice has been created", WebhookEventType.InvoiceCreated), - ("A new payment has been received", WebhookEventType.InvoiceReceivedPayment), - ("A payment has been settled", WebhookEventType.InvoicePaymentSettled), - ("An invoice is processing", WebhookEventType.InvoiceProcessing), - ("An invoice has expired", WebhookEventType.InvoiceExpired), - ("An invoice has been settled", WebhookEventType.InvoiceSettled), - ("An invoice became invalid", WebhookEventType.InvoiceInvalid) - }) + @foreach (var evt in WebhookSender.GetSupportedWebhookTypes()) {
- - + +
}
diff --git a/BTCPayServer/Views/UIStores/StoreEmails.cshtml b/BTCPayServer/Views/UIStores/StoreEmails.cshtml index 35871070c..a4aa4f297 100644 --- a/BTCPayServer/Views/UIStores/StoreEmails.cshtml +++ b/BTCPayServer/Views/UIStores/StoreEmails.cshtml @@ -1,5 +1,6 @@ -@using BTCPayServer.Client.Models +@using BTCPayServer.HostedServices.Webhooks @model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel +@inject WebhookSender WebhookSender @{ Layout = "../Shared/_NavLayout.cshtml"; @@ -54,7 +55,7 @@
- +
Choose what event sends the email.
@@ -78,17 +79,47 @@ -
-
Placeholders:
-
- {Invoice.Id}, - {Invoice.StoreId}, - {Invoice.Price}, - {Invoice.Currency}, - {Invoice.Status}, - {Invoice.AdditionalStatus}, - {Invoice.OrderId} -
+
+ + + + + + + + + + + + + + + +
Placeholders
Invoice + {Invoice.Id}, + {Invoice.StoreId}, + {Invoice.Price}, + {Invoice.Currency}, + {Invoice.Status}, + {Invoice.AdditionalStatus}, + {Invoice.OrderId} + {Invoice.Metadata}* +
Request + {PaymentRequest.Id}, + {PaymentRequest.Price}, + {PaymentRequest.Currency}, + {PaymentRequest.Title}, + {PaymentRequest.Description}, + {PaymentRequest.Status} + {PaymentRequest.FormResponse}* +
Payout + {Payout.Id}, + {Payout.PullPaymentId}, + {Payout.Destination}, + {Payout.State} + {Payout.Metadata}* +
* These fields are JSON objects. You can access properties within them using this syntax. One example is {Invoice.Metadata.itemCode} +
diff --git a/BTCPayServer/Views/UIStores/TestWebhook.cshtml b/BTCPayServer/Views/UIStores/TestWebhook.cshtml index 0dd13022a..ebac6544f 100644 --- a/BTCPayServer/Views/UIStores/TestWebhook.cshtml +++ b/BTCPayServer/Views/UIStores/TestWebhook.cshtml @@ -1,5 +1,6 @@ +@using BTCPayServer.HostedServices.Webhooks @model EditWebhookViewModel -@using BTCPayServer.Client.Models; +@inject WebhookSender WebhookSender @{ Layout = "../Shared/_NavLayout.cshtml"; ViewData.SetActivePage(StoreNavPages.Webhooks, "Send a test event to a webhook endpoint", Context.GetStoreData().Id); @@ -12,7 +13,7 @@