From c042141bf548ce380eb18e9b50e21b78ba4ca77a Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:29:20 -0400 Subject: [PATCH] fix: emit a consistent webhook payload from both dispatch paths The two webhook dispatch paths drifted in payload shape: - GrinService.DispatchWebhook (checkout / broadcast -> InvoiceProcessing) sent top-level `invoiceId` + `storeId`, `invoice.confirmations`, and `metadata.order_id`. - GrinPaymentMonitorService.DispatchWebhookAsync (settled / invalid / expired) omitted `invoiceId`, `storeId` and `confirmations`, and used `metadata.medusa_cart_id` instead of `metadata.order_id`. So a webhook consumer that handles both broadcast and settlement events sees two different shapes, and any consumer keyed on `metadata.order_id` silently drops every settlement/expiry (the broadcast arrives, but the order never advances or cancels). The in-code comments claiming the two paths are kept "bit-for-bit identical" only ever applied to the signature encoding, not the payload. Extract a single GrinWebhookPayload.Build() used by both paths so they can't drift. To stay backward compatible the order reference is emitted under BOTH `order_id` and `medusa_cart_id` (same value), and the monitor path now also includes `invoiceId`, `storeId` and `confirmations`. No signature-format change (WebhookSignatureTests still pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/GrinPaymentMonitorService.cs | 17 +------- .../Services/GrinService.cs | 19 +-------- .../Services/GrinWebhookPayload.cs | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 BTCPayServer.Plugins.Grin/Services/GrinWebhookPayload.cs diff --git a/BTCPayServer.Plugins.Grin/Services/GrinPaymentMonitorService.cs b/BTCPayServer.Plugins.Grin/Services/GrinPaymentMonitorService.cs index e61ec25..94ae159 100644 --- a/BTCPayServer.Plugins.Grin/Services/GrinPaymentMonitorService.cs +++ b/BTCPayServer.Plugins.Grin/Services/GrinPaymentMonitorService.cs @@ -261,22 +261,7 @@ private async Task DispatchWebhookAsync(GrinInvoice invoice, GrinStoreSettings s try { - var amountGrin = invoice.AmountNanogrin / 1_000_000_000m; - var payload = new - { - @event = eventType, - invoice = new - { - id = invoice.Id, - status = invoice.Status.ToString(), - amount = amountGrin, - metadata = new - { - session_id = invoice.SessionId ?? "", - medusa_cart_id = invoice.OrderId ?? "", - } - } - }; + var payload = GrinWebhookPayload.Build(invoice, eventType); var json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); diff --git a/BTCPayServer.Plugins.Grin/Services/GrinService.cs b/BTCPayServer.Plugins.Grin/Services/GrinService.cs index 3f758ab..ee0dddb 100644 --- a/BTCPayServer.Plugins.Grin/Services/GrinService.cs +++ b/BTCPayServer.Plugins.Grin/Services/GrinService.cs @@ -204,24 +204,7 @@ public async Task DispatchWebhook(GrinStoreSettings settings, GrinInvoice invoic try { - var payload = new - { - @event = eventType, - invoiceId = invoice.Id, - storeId = invoice.StoreId, - invoice = new - { - id = invoice.Id, - status = invoice.Status.ToString(), - amount = invoice.AmountNanogrin / 1_000_000_000m, - confirmations = invoice.Confirmations, - metadata = new - { - session_id = invoice.SessionId ?? "", - order_id = invoice.OrderId ?? "", - }, - }, - }; + var payload = GrinWebhookPayload.Build(invoice, eventType); var json = System.Text.Json.JsonSerializer.Serialize(payload); var body = System.Text.Encoding.UTF8.GetBytes(json); diff --git a/BTCPayServer.Plugins.Grin/Services/GrinWebhookPayload.cs b/BTCPayServer.Plugins.Grin/Services/GrinWebhookPayload.cs new file mode 100644 index 0000000..6227ad8 --- /dev/null +++ b/BTCPayServer.Plugins.Grin/Services/GrinWebhookPayload.cs @@ -0,0 +1,40 @@ +using BTCPayServer.Plugins.Grin.Data; + +namespace BTCPayServer.Plugins.Grin.Services; + +/// +/// Single source of truth for the webhook JSON payload, shared by both dispatch +/// paths — (checkout / broadcast → +/// InvoiceProcessing) and (settled / +/// invalid / expired). Previously each path built its own anonymous object and +/// they had drifted: the monitor omitted the top-level invoiceId/ +/// storeId and confirmations, and used metadata.medusa_cart_id +/// while the checkout path used metadata.order_id. Consumers that handle +/// both broadcast and settlement events therefore had to special-case two +/// shapes (and any keyed only on order_id silently missed settlements). +/// +/// To stay backward compatible, the order reference is emitted under BOTH +/// order_id and medusa_cart_id (same value). +/// +public static class GrinWebhookPayload +{ + public static object Build(GrinInvoice invoice, string eventType) => new + { + @event = eventType, + invoiceId = invoice.Id, + storeId = invoice.StoreId, + invoice = new + { + id = invoice.Id, + status = invoice.Status.ToString(), + amount = invoice.AmountNanogrin / 1_000_000_000m, + confirmations = invoice.Confirmations, + metadata = new + { + session_id = invoice.SessionId ?? "", + order_id = invoice.OrderId ?? "", + medusa_cart_id = invoice.OrderId ?? "", + } + } + }; +}