Skip to content

Commit 3d6dc38

Browse files
fix(billing): tolerate Razorpay notes:[] (polymorphic) in webhook entities (#274)
* fix(billing): tolerate Razorpay notes:[] (polymorphic) in webhook entities 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) <noreply@anthropic.com> * 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). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3779072 commit 3d6dc38

2 files changed

Lines changed: 110 additions & 8 deletions

File tree

internal/handlers/billing.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,12 +1350,37 @@ type rzpEntityWrapper struct {
13501350
Entity json.RawMessage `json:"entity"`
13511351
}
13521352

1353+
// rzpNotes is Razorpay's `notes` field. It is POLYMORPHIC on the wire: an
1354+
// OBJECT ({"team_id":"…"}) when populated, but an empty ARRAY ([]) when there
1355+
// are no notes. A plain map[string]string fails to unmarshal the [] form with
1356+
// "cannot unmarshal array into Go struct field …notes of type map[string]string"
1357+
// — which silently broke handlePaymentFailed for every payment.failed webhook
1358+
// carrying notes:[] (the immediate payment-failure path). UnmarshalJSON
1359+
// tolerates object / empty-array / null, decoding only the object form.
1360+
// Found by the live failure-path test (2026-06-07).
1361+
type rzpNotes map[string]string
1362+
1363+
func (n *rzpNotes) UnmarshalJSON(b []byte) error {
1364+
s := strings.TrimSpace(string(b))
1365+
// null, empty, or any array form (Razorpay's "no notes" is `[]`) → empty map.
1366+
if s == "" || s == "null" || (len(s) > 0 && s[0] == '[') {
1367+
*n = rzpNotes{}
1368+
return nil
1369+
}
1370+
m := map[string]string{}
1371+
if err := json.Unmarshal(b, &m); err != nil {
1372+
return err
1373+
}
1374+
*n = m
1375+
return nil
1376+
}
1377+
13531378
type rzpSubscriptionEntity struct {
1354-
ID string `json:"id"`
1355-
PlanID string `json:"plan_id"`
1356-
Status string `json:"status"`
1357-
Notes map[string]string `json:"notes"`
1358-
PaidCount *int64 `json:"paid_count"`
1379+
ID string `json:"id"`
1380+
PlanID string `json:"plan_id"`
1381+
Status string `json:"status"`
1382+
Notes rzpNotes `json:"notes"`
1383+
PaidCount *int64 `json:"paid_count"`
13591384
}
13601385

13611386
type rzpPaymentEntity struct {
@@ -1372,9 +1397,9 @@ type rzpPaymentEntity struct {
13721397
// `notes` for any caller-supplied metadata (Razorpay copies notes
13731398
// from the parent subscription onto the payment). resolveTeamFromPayment
13741399
// reads these in priority order.
1375-
SubscriptionID string `json:"subscription_id"`
1376-
OrderID string `json:"order_id"`
1377-
Notes map[string]string `json:"notes"`
1400+
SubscriptionID string `json:"subscription_id"`
1401+
OrderID string `json:"order_id"`
1402+
Notes rzpNotes `json:"notes"`
13781403
}
13791404

13801405
// RazorpayWebhook handles POST /razorpay/webhook.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package handlers
2+
3+
// billing_notes_unmarshal_test.go — regression for the Razorpay `notes`
4+
// polymorphism bug found by the live failure-path test (2026-06-07): Razorpay
5+
// sends `notes` as an OBJECT when populated but an empty ARRAY ([]) when absent.
6+
// The old `Notes map[string]string` failed to unmarshal the [] form, so every
7+
// payment.failed webhook with no notes hit
8+
// "cannot unmarshal array into Go struct field …notes of type map[string]string"
9+
// and the immediate payment-failure handling was skipped. rzpNotes tolerates
10+
// object / array / null.
11+
12+
import (
13+
"encoding/json"
14+
"testing"
15+
)
16+
17+
func TestRzpNotes_ToleratesArrayObjectAndNull(t *testing.T) {
18+
t.Parallel()
19+
20+
// payment.failed with no notes → Razorpay sends notes:[] (the bug trigger).
21+
t.Run("payment entity notes:[] parses to empty map", func(t *testing.T) {
22+
t.Parallel()
23+
var p rzpPaymentEntity
24+
err := json.Unmarshal([]byte(`{"id":"pay_x","subscription_id":"sub_x","notes":[]}`), &p)
25+
if err != nil {
26+
t.Fatalf("notes:[] must not error, got %v", err)
27+
}
28+
if p.Notes == nil || len(p.Notes) != 0 {
29+
t.Fatalf("notes:[] must decode to an empty map, got %#v", p.Notes)
30+
}
31+
if p.SubscriptionID != "sub_x" {
32+
t.Fatalf("other fields must still decode; got sub=%q", p.SubscriptionID)
33+
}
34+
})
35+
36+
// subscription with team_id notes (the happy path that must keep working).
37+
t.Run("subscription entity notes:{team_id} decodes", func(t *testing.T) {
38+
t.Parallel()
39+
var s rzpSubscriptionEntity
40+
err := json.Unmarshal([]byte(`{"id":"sub_y","plan_id":"plan_y","notes":{"team_id":"abc"}}`), &s)
41+
if err != nil {
42+
t.Fatalf("object notes must decode, got %v", err)
43+
}
44+
if s.Notes["team_id"] != "abc" {
45+
t.Fatalf("team_id must round-trip; got %#v", s.Notes)
46+
}
47+
})
48+
49+
// A malformed object (non-string value) must surface the decode error rather
50+
// than silently swallow it — covers the json.Unmarshal error branch.
51+
t.Run("object with non-string value errors", func(t *testing.T) {
52+
t.Parallel()
53+
var p rzpPaymentEntity
54+
err := json.Unmarshal([]byte(`{"id":"p","notes":{"k":123}}`), &p)
55+
if err == nil {
56+
t.Fatal("a notes object with a non-string value must return a decode error, not be swallowed")
57+
}
58+
})
59+
60+
// null and empty-array on the subscription entity → empty map, no error.
61+
for _, tc := range []struct{ name, body string }{
62+
{"null", `{"id":"s","notes":null}`},
63+
{"empty array", `{"id":"s","notes":[]}`},
64+
} {
65+
tc := tc
66+
t.Run("subscription notes "+tc.name, func(t *testing.T) {
67+
t.Parallel()
68+
var s rzpSubscriptionEntity
69+
if err := json.Unmarshal([]byte(tc.body), &s); err != nil {
70+
t.Fatalf("%s notes must not error, got %v", tc.name, err)
71+
}
72+
if s.Notes == nil {
73+
t.Fatalf("%s notes must be a non-nil empty map", tc.name)
74+
}
75+
})
76+
}
77+
}

0 commit comments

Comments
 (0)