Repository: btcpayserver/btcpayserver (csharp)

Issue:
paidPartial email trigger
### Discussed in https://github.com/btcpayserver/btcpayserver/discussions/5920

<div type='discussions-op-text'>

<sup>Originally posted by **earlingeling** April  9, 2024</sup>
Sometimes customers pay insufficient funds and get their invoice marked as New (paidPartial).

The reasoning varies - if they use large crypto-exchanges like binance, coinbase and similar, they often take a rather high fee. Also, they might also take their flat btc fee out of the receivers end of the transaction.

To remedy these situations it would be really helpful to be able to automatically trigger an email rule for paidPartial.
Then the store can give in-depth instructions on how this happens and how they can solve it themselv - and largely remove customers enquiries about paidPartial invoices.

Also, an {Invoice.url} link to their invoice would be helpful so we can link them back to their invoice (especially for stores with modal btcpay overlays at checkout)
</div>

Pull request description:
Resolves: #5936

Patch:
diff --git a/BTCPayServer.Client/Models/WebhookEventType.cs b/BTCPayServer.Client/Models/WebhookEventType.cs
index b7c1e6bcb2..db168ebc1c 100644
--- a/BTCPayServer.Client/Models/WebhookEventType.cs
+++ b/BTCPayServer.Client/Models/WebhookEventType.cs
@@ -5,11 +5,13 @@ public static class WebhookEventType
 {
     public const string InvoiceCreated = nameof(InvoiceCreated);
     public const string InvoiceReceivedPayment = nameof(InvoiceReceivedPayment);
+    public const string InvoicePaymentSettled = nameof(InvoicePaymentSettled);
     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 InvoiceExpiredPaidPartial = nameof(InvoiceExpiredPaidPartial);
+    public const string InvoicePaidAfterExpiration = nameof(InvoicePaidAfterExpiration);
     public const string PayoutCreated = nameof(PayoutCreated);
     public const string PayoutApproved = nameof(PayoutApproved);
     public const string PayoutUpdated = nameof(PayoutUpdated);
@@ -18,5 +20,4 @@ public static class WebhookEventType
     public const string PaymentRequestArchived = nameof(PaymentRequestArchived);
     public const string PaymentRequestStatusChanged = nameof(PaymentRequestStatusChanged);
     public const string PaymentRequestCompleted = nameof(PaymentRequestCompleted);
-    
 }
diff --git a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs
index de40a0808f..a26ea51629 100644
--- a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs
+++ b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs
@@ -18,7 +18,7 @@ public WebhookPayoutEvent(string type, string 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 type, string storeId)
@@ -32,12 +32,12 @@ public WebhookPaymentRequestEvent(string type, string storeId)
         [JsonProperty(Order = 2)] public string PaymentRequestId { get; set; }
         [JsonProperty(Order = 3)] [JsonConverter(typeof(StringEnumConverter))]public PaymentRequestStatus Status { get; set; }
     }
-    
+
     public abstract class StoreWebhookEvent : WebhookEvent
     {
         [JsonProperty(Order = 1)] public string StoreId { get; set; }
     }
-    
+
     public class WebhookInvoiceEvent : StoreWebhookEvent
     {
         public WebhookInvoiceEvent()
@@ -45,7 +45,7 @@ public WebhookInvoiceEvent()
         }
 
         public WebhookInvoiceEvent(string evtType, string storeId)
-        { 
+        {
             if (!evtType.StartsWith("invoice", StringComparison.InvariantCultureIgnoreCase))
                 throw new ArgumentException("Invalid event type", nameof(evtType));
             Type = evtType;
diff --git a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs
index 7be8a8546e..107634d9d7 100644
--- a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs
+++ b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs
@@ -29,6 +29,8 @@ public override Dictionary<string, string> GetSupportedWebhookTypes()
             {WebhookEventType.InvoiceExpired, "Invoice - Expired"},
             {WebhookEventType.InvoiceSettled, "Invoice - Is Settled"},
             {WebhookEventType.InvoiceInvalid, "Invoice - Became Invalid"},
+            {WebhookEventType.InvoiceExpiredPaidPartial, "Invoice - Expired Paid Partial"},
+            {WebhookEventType.InvoicePaidAfterExpiration, "Invoice - Expired Paid Late"},
         };
     }
 
@@ -113,6 +115,10 @@ protected override WebhookInvoiceEvent GetWebhookEvent(InvoiceEvent invoiceEvent
                     Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
                     StoreId = invoiceEvent.Invoice.StoreId
                 };
+            case InvoiceEventCode.ExpiredPaidPartial:
+                return new WebhookInvoiceEvent(WebhookEventType.InvoiceExpiredPaidPartial, storeId);
+            case InvoiceEventCode.PaidAfterExpiration:
+                return new WebhookInvoiceEvent(WebhookEventType.InvoicePaidAfterExpiration, storeId);
             default:
                 return null;
         }
diff --git a/BTCPayServer/Services/CallbackGenerator.cs b/BTCPayServer/Services/CallbackGenerator.cs
index 3b0bb9f711..2e8dbd9aee 100644
--- a/BTCPayServer/Services/CallbackGenerator.cs
+++ b/BTCPayServer/Services/CallbackGenerator.cs
@@ -116,5 +116,17 @@ public string PaymentRequestListLink(string storeId, HttpRequest request)
                 pathBase: request.PathBase
             ) ?? throw Bug();
         }
+
+        public string InvoiceLink(string invoiceId, HttpRequest request)
+        {
+            return LinkGenerator.GetUriByAction(
+                action: nameof(UIInvoiceController.Invoice),
+                controller: "UIInvoice",
+                values: new { invoiceId },
+                scheme: request.Scheme,
+                host: request.Host,
+                pathBase: request.PathBase
+            ) ?? throw Bug();
+        }
     }
 }
diff --git a/BTCPayServer/Views/UIStores/StoreEmailRulesManage.cshtml b/BTCPayServer/Views/UIStores/StoreEmailRulesManage.cshtml
index c5d4535526..2a04c5aa5c 100644
--- a/BTCPayServer/Views/UIStores/StoreEmailRulesManage.cshtml
+++ b/BTCPayServer/Views/UIStores/StoreEmailRulesManage.cshtml
@@ -42,7 +42,7 @@
     <div class="form-group">
         <label asp-for="Trigger" class="form-label" data-required></label>
         <select asp-for="Trigger" asp-items="@WebhookSender.GetSupportedWebhookTypes()
-                .OrderBy(a=>a.Value).Select(s => new SelectListItem(s.Value, s.Key))" 
+                .OrderBy(a=>a.Value).Select(s => new SelectListItem(s.Value, s.Key))"
                 class="form-select email-rule-trigger" required></select>
         <span asp-validation-for="Trigger" class="text-danger"></span>
         <div class="form-text" text-translate="true">Choose what event sends the email.</div>
@@ -66,7 +66,7 @@
         <input type="text" asp-for="Subject" class="form-control email-rule-subject" />
         <span asp-validation-for="Subject" class="text-danger"></span>
     </div>
-    
+
     <div class="form-group">
         <label asp-for="Body" class="form-label" data-required></label>
         <textarea asp-for="Body" class="form-control richtext email-rule-body" rows="4"></textarea>
@@ -163,6 +163,17 @@
                     subject: 'Invoice {Invoice.Id} payment settled',
                     body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.'
                 },
+                @{ var invoiceLink = CallbackGenerator.InvoiceLink("INVOICE_ID", this.Context.Request).Replace("INVOICE_ID", ""); }
+                @WebhookEventType.InvoiceExpiredPaidPartial: {
+                    subject: 'Invoice {Invoice.Id} Expired Paid Partial',
+                    body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) has expired partially paid. '+
+                        '\nPlease review and take appropriate action: ' + @Safe.Json(invoiceLink) + '{Invoice.Id}'
+                },
+                @WebhookEventType.InvoicePaidAfterExpiration: {
+                    subject: 'Invoice {Invoice.Id} Expired Paid Late',
+                    body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) has been paid after expiration. '+
+                        '\nPlease review and take appropriate action: ' + @Safe.Json(invoiceLink) + '{Invoice.Id}'
+                },
                 @{ var paymentRequestsLink = CallbackGenerator.PaymentRequestListLink(storeId, this.Context.Request); }
                 @WebhookEventType.PaymentRequestCompleted: {
                     subject: 'Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed',


Test patch:
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 15b2750ff5..7e7925b020 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -2102,6 +2102,61 @@ await TestUtils.EventuallyAsync(async () =>
         }
 
 
+        [Fact()]
+        [Trait("Integration", "Integration")]
+        public async Task EnsureWebhooksInvoiceExpiredPaidLatePartial()
+        {
+            using var tester = CreateServerTester();
+            await tester.StartAsync();
+            var user = tester.NewAccount();
+            await user.GrantAccessAsync();
+            user.RegisterDerivationScheme("BTC");
+            await user.SetupWebhook();
+            var client = await user.CreateClient();
+
+            // PaidAfterExpiration
+            var invoicePaidAfterExpiration = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = 0.01m, Currency = "BTC" });
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x) => Assert.Equal(invoicePaidAfterExpiration.Id, x.InvoiceId));
+
+            await tester.PayTester.InvoiceRepository.UpdateInvoiceExpiry(invoicePaidAfterExpiration.Id, TimeSpan.FromSeconds(0));
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoiceExpired, (WebhookInvoiceEvent x) => Assert.Equal(invoicePaidAfterExpiration.Id, x.InvoiceId));
+
+            var inv = new BitcoinUrlBuilder((await client.GetInvoicePaymentMethods(user.StoreId, invoicePaidAfterExpiration.Id)).Single(model =>
+                    PaymentMethodId.Parse(model.PaymentMethodId) ==
+                    PaymentTypes.CHAIN.GetPaymentMethodId("BTC"))
+                .PaymentLink, tester.ExplorerNode.Network);
+            await tester.ExplorerNode.SendToAddressAsync(inv.Address, Money.Coins(inv.Amount.ToDecimal(MoneyUnit.BTC)));
+            await Task.Delay(1000);
+
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoicePaidAfterExpiration, (WebhookInvoiceEvent evt) =>
+            {
+                Assert.Equal(invoicePaidAfterExpiration.Id, evt.InvoiceId);
+            });
+
+            // ExpiredPaidPartial
+            var invoiceExpiredPartial = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
+            {
+                Amount = 0.01m, Currency = "BTC", Checkout = new InvoiceDataBase.CheckoutOptions {
+                    Expiration = TimeSpan.FromMinutes(1)
+                }
+            });
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x) => Assert.Equal(invoiceExpiredPartial.Id, x.InvoiceId));
+
+            inv = new BitcoinUrlBuilder((await client.GetInvoicePaymentMethods(user.StoreId, invoiceExpiredPartial.Id)).Single(model =>
+                    PaymentMethodId.Parse(model.PaymentMethodId) ==
+                    PaymentTypes.CHAIN.GetPaymentMethodId("BTC"))
+                .PaymentLink, tester.ExplorerNode.Network);
+            await tester.ExplorerNode.SendToAddressAsync(inv.Address, Money.Coins(inv.Amount.ToDecimal(MoneyUnit.BTC)/2m));
+
+            await tester.PayTester.InvoiceRepository.UpdateInvoiceExpiry(invoiceExpiredPartial.Id, TimeSpan.FromSeconds(1));
+            await Task.Delay(2000); // give it time to expire and process payments
+
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoiceExpired, (WebhookInvoiceEvent x) => Assert.Equal(invoiceExpiredPartial.Id, x.InvoiceId));
+            await user.AssertHasWebhookEvent(WebhookEventType.InvoiceExpiredPaidPartial, (WebhookInvoiceEvent evt) =>
+            {
+                Assert.Equal(invoiceExpiredPartial.Id, evt.InvoiceId);
+            });
+        }
 
 
         [Fact()]