From 9ee526068b5beba9757b7f263d922e3d7568c5df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 12:05:25 +0530 Subject: [PATCH 1/2] fix(billing): tolerate Razorpay notes:[] (polymorphic) in webhook entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Razorpay's `notes` field is polymorphic: an OBJECT when populated, an empty ARRAY ([]) when absent. The structs used map[string]string, so a payment.failed webhook carrying notes:[] failed to parse: json: cannot unmarshal array into Go struct field rzpPaymentEntity.notes of type map[string]string → handlePaymentFailed swallowed the event and the immediate payment-failure handling (SendPaymentFailed) never ran. Found by the live failure-path test (bank-sim 'Failure' button → payment.failed with notes:[]). New rzpNotes type with UnmarshalJSON that tolerates object / empty-array / null (decoding only the object form), used by both the subscription and payment entities. Tier-upgrade safety is unaffected (a failed payment never carried a subscription.charged); this restores the failure-NOTIFICATION path. Test: TestRzpNotes_ToleratesArrayObjectAndNull (notes:[] / {} / null / object). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/billing.go | 41 +++++++++--- .../handlers/billing_notes_unmarshal_test.go | 66 +++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/billing_notes_unmarshal_test.go diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index bc64e65..b2c8737 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -1350,12 +1350,37 @@ type rzpEntityWrapper struct { Entity json.RawMessage `json:"entity"` } +// rzpNotes is Razorpay's `notes` field. It is POLYMORPHIC on the wire: an +// OBJECT ({"team_id":"…"}) when populated, but an empty ARRAY ([]) when there +// are no notes. A plain map[string]string fails to unmarshal the [] form with +// "cannot unmarshal array into Go struct field …notes of type map[string]string" +// — which silently broke handlePaymentFailed for every payment.failed webhook +// carrying notes:[] (the immediate payment-failure path). UnmarshalJSON +// tolerates object / empty-array / null, decoding only the object form. +// Found by the live failure-path test (2026-06-07). +type rzpNotes map[string]string + +func (n *rzpNotes) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + // null, empty, or any array form (Razorpay's "no notes" is `[]`) → empty map. + if s == "" || s == "null" || (len(s) > 0 && s[0] == '[') { + *n = rzpNotes{} + return nil + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + return err + } + *n = m + return nil +} + type rzpSubscriptionEntity struct { - ID string `json:"id"` - PlanID string `json:"plan_id"` - Status string `json:"status"` - Notes map[string]string `json:"notes"` - PaidCount *int64 `json:"paid_count"` + ID string `json:"id"` + PlanID string `json:"plan_id"` + Status string `json:"status"` + Notes rzpNotes `json:"notes"` + PaidCount *int64 `json:"paid_count"` } type rzpPaymentEntity struct { @@ -1372,9 +1397,9 @@ type rzpPaymentEntity struct { // `notes` for any caller-supplied metadata (Razorpay copies notes // from the parent subscription onto the payment). resolveTeamFromPayment // reads these in priority order. - SubscriptionID string `json:"subscription_id"` - OrderID string `json:"order_id"` - Notes map[string]string `json:"notes"` + SubscriptionID string `json:"subscription_id"` + OrderID string `json:"order_id"` + Notes rzpNotes `json:"notes"` } // RazorpayWebhook handles POST /razorpay/webhook. diff --git a/internal/handlers/billing_notes_unmarshal_test.go b/internal/handlers/billing_notes_unmarshal_test.go new file mode 100644 index 0000000..071a1b4 --- /dev/null +++ b/internal/handlers/billing_notes_unmarshal_test.go @@ -0,0 +1,66 @@ +package handlers + +// billing_notes_unmarshal_test.go — regression for the Razorpay `notes` +// polymorphism bug found by the live failure-path test (2026-06-07): Razorpay +// sends `notes` as an OBJECT when populated but an empty ARRAY ([]) when absent. +// The old `Notes map[string]string` failed to unmarshal the [] form, so every +// payment.failed webhook with no notes hit +// "cannot unmarshal array into Go struct field …notes of type map[string]string" +// and the immediate payment-failure handling was skipped. rzpNotes tolerates +// object / array / null. + +import ( + "encoding/json" + "testing" +) + +func TestRzpNotes_ToleratesArrayObjectAndNull(t *testing.T) { + t.Parallel() + + // payment.failed with no notes → Razorpay sends notes:[] (the bug trigger). + t.Run("payment entity notes:[] parses to empty map", func(t *testing.T) { + t.Parallel() + var p rzpPaymentEntity + err := json.Unmarshal([]byte(`{"id":"pay_x","subscription_id":"sub_x","notes":[]}`), &p) + if err != nil { + t.Fatalf("notes:[] must not error, got %v", err) + } + if p.Notes == nil || len(p.Notes) != 0 { + t.Fatalf("notes:[] must decode to an empty map, got %#v", p.Notes) + } + if p.SubscriptionID != "sub_x" { + t.Fatalf("other fields must still decode; got sub=%q", p.SubscriptionID) + } + }) + + // subscription with team_id notes (the happy path that must keep working). + t.Run("subscription entity notes:{team_id} decodes", func(t *testing.T) { + t.Parallel() + var s rzpSubscriptionEntity + err := json.Unmarshal([]byte(`{"id":"sub_y","plan_id":"plan_y","notes":{"team_id":"abc"}}`), &s) + if err != nil { + t.Fatalf("object notes must decode, got %v", err) + } + if s.Notes["team_id"] != "abc" { + t.Fatalf("team_id must round-trip; got %#v", s.Notes) + } + }) + + // null and empty-array on the subscription entity → empty map, no error. + for _, tc := range []struct{ name, body string }{ + {"null", `{"id":"s","notes":null}`}, + {"empty array", `{"id":"s","notes":[]}`}, + } { + tc := tc + t.Run("subscription notes "+tc.name, func(t *testing.T) { + t.Parallel() + var s rzpSubscriptionEntity + if err := json.Unmarshal([]byte(tc.body), &s); err != nil { + t.Fatalf("%s notes must not error, got %v", tc.name, err) + } + if s.Notes == nil { + t.Fatalf("%s notes must be a non-nil empty map", tc.name) + } + }) + } +} From 1755ad496349d0807c6c225055ed3211643d280d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 12:36:10 +0530 Subject: [PATCH 2/2] test(billing): cover rzpNotes json.Unmarshal error branch (non-string value) 100%-patch: a notes object with a non-string value exercises the decode-error path in rzpNotes.UnmarshalJSON (billing.go:1372-1373). --- internal/handlers/billing_notes_unmarshal_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/handlers/billing_notes_unmarshal_test.go b/internal/handlers/billing_notes_unmarshal_test.go index 071a1b4..cb6a36a 100644 --- a/internal/handlers/billing_notes_unmarshal_test.go +++ b/internal/handlers/billing_notes_unmarshal_test.go @@ -46,6 +46,17 @@ func TestRzpNotes_ToleratesArrayObjectAndNull(t *testing.T) { } }) + // A malformed object (non-string value) must surface the decode error rather + // than silently swallow it — covers the json.Unmarshal error branch. + t.Run("object with non-string value errors", func(t *testing.T) { + t.Parallel() + var p rzpPaymentEntity + err := json.Unmarshal([]byte(`{"id":"p","notes":{"k":123}}`), &p) + if err == nil { + t.Fatal("a notes object with a non-string value must return a decode error, not be swallowed") + } + }) + // null and empty-array on the subscription entity → empty map, no error. for _, tc := range []struct{ name, body string }{ {"null", `{"id":"s","notes":null}`},