tenseleyflow/shithub / 8d95c1f

Browse files

orgs tests: charge.refunded flips invoice + no-op on unknown invoice / standalone refund

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8d95c1f261ab1d94a0d7edc2b3ab2bca46e1cbf2
Parents
7ea4fee
Tree
114f586

1 changed file

StatusFile+-
A internal/web/handlers/orgs/billing_webhook_refund_test.go 169 0
internal/web/handlers/orgs/billing_webhook_refund_test.goadded
@@ -0,0 +1,169 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"net/http"
9
+	"testing"
10
+
11
+	stripeapi "github.com/stripe/stripe-go/v85"
12
+
13
+	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
14
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
16
+)
17
+
18
+// PRO08 D2 refund tests.
19
+//
20
+// charge.refunded events arrive out-of-band after a Stripe-side refund.
21
+// The invoice itself stays paid in Stripe; shithub flips its own row
22
+// to status='refunded' for UI surfacing.
23
+
24
+func TestBillingWebhookChargeRefundedMarksInvoiceRefunded(t *testing.T) {
25
+	t.Parallel()
26
+	ctx := context.Background()
27
+	pool := dbtest.NewTestDB(t)
28
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
29
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
30
+
31
+	// Seed a paid invoice via the polymorphic upsert.
32
+	if _, err := orgbilling.UpsertInvoiceForPrincipal(ctx, orgbilling.Deps{Pool: pool}, orgbilling.PrincipalForOrg(orgID), orgbilling.InvoiceSnapshot{
33
+		StripeInvoiceID:  "in_paid_1",
34
+		StripeCustomerID: "cus_refund",
35
+		Status:           orgbilling.InvoiceStatusPaid,
36
+		Number:           "INV-001",
37
+		Currency:         "usd",
38
+		AmountDueCents:   400,
39
+		AmountPaidCents:  400,
40
+	}); err != nil {
41
+		t.Fatalf("seed invoice: %v", err)
42
+	}
43
+
44
+	raw, err := json.Marshal(map[string]any{
45
+		"id":       "ch_refund_1",
46
+		"invoice":  "in_paid_1",
47
+		"customer": "cus_refund",
48
+	})
49
+	if err != nil {
50
+		t.Fatalf("marshal: %v", err)
51
+	}
52
+	fake := &fakeStripeRemote{
53
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
54
+			return stripeapi.Event{
55
+				ID:   "evt_refund_1",
56
+				Type: stripeapi.EventType("charge.refunded"),
57
+				Data: &stripeapi.EventData{Raw: raw},
58
+			}, nil
59
+		},
60
+	}
61
+	mux := newOrgBillingMux(t, pool, ownerID, fake)
62
+	resp := postBillingWebhook(t, mux, "evt_refund_1")
63
+	if resp.Code != http.StatusOK {
64
+		t.Fatalf("charge.refunded status=%d body=%s", resp.Code, resp.Body.String())
65
+	}
66
+
67
+	// Verify the invoice was flipped to refunded.
68
+	var status billingdb.BillingInvoiceStatus
69
+	var refundedAtValid bool
70
+	if err := pool.QueryRow(ctx,
71
+		`SELECT status, refunded_at IS NOT NULL FROM billing_invoices WHERE stripe_invoice_id = 'in_paid_1'`,
72
+	).Scan(&status, &refundedAtValid); err != nil {
73
+		t.Fatalf("query invoice: %v", err)
74
+	}
75
+	if status != billingdb.BillingInvoiceStatusRefunded {
76
+		t.Fatalf("invoice status: got %q, want refunded", status)
77
+	}
78
+	if !refundedAtValid {
79
+		t.Fatalf("refunded_at not set")
80
+	}
81
+
82
+	// Verify the receipt records the subject.
83
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_refund_1")
84
+	if err != nil {
85
+		t.Fatalf("get receipt: %v", err)
86
+	}
87
+	if !receipt.SubjectKind.Valid || receipt.SubjectKind.BillingSubjectKind != billingdb.BillingSubjectKindOrg {
88
+		t.Errorf("receipt subject_kind: got %+v, want org", receipt.SubjectKind)
89
+	}
90
+	if !receipt.SubjectID.Valid || receipt.SubjectID.Int64 != orgID {
91
+		t.Errorf("receipt subject_id: got %+v, want %d", receipt.SubjectID, orgID)
92
+	}
93
+}
94
+
95
+// TestBillingWebhookChargeRefundedForUnknownInvoiceIsNoOp locks PRO08
96
+// D2's degraded-path behavior: a refund for an invoice we've never
97
+// seen logs a warning and returns 200 so Stripe stops retrying. The
98
+// operator reconciles manually.
99
+func TestBillingWebhookChargeRefundedForUnknownInvoiceIsNoOp(t *testing.T) {
100
+	t.Parallel()
101
+	ctx := context.Background()
102
+	pool := dbtest.NewTestDB(t)
103
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
104
+	_ = insertOrgAvatarOrg(t, pool, ownerID, "acme")
105
+
106
+	raw, err := json.Marshal(map[string]any{
107
+		"id":       "ch_ghost",
108
+		"invoice":  "in_never_seen",
109
+		"customer": "cus_ghost",
110
+	})
111
+	if err != nil {
112
+		t.Fatalf("marshal: %v", err)
113
+	}
114
+	fake := &fakeStripeRemote{
115
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
116
+			return stripeapi.Event{
117
+				ID:   "evt_refund_ghost",
118
+				Type: stripeapi.EventType("charge.refunded"),
119
+				Data: &stripeapi.EventData{Raw: raw},
120
+			}, nil
121
+		},
122
+	}
123
+	mux := newOrgBillingMux(t, pool, ownerID, fake)
124
+	resp := postBillingWebhook(t, mux, "evt_refund_ghost")
125
+	if resp.Code != http.StatusOK {
126
+		t.Fatalf("ghost refund status=%d body=%s (expected 200 no-op)", resp.Code, resp.Body.String())
127
+	}
128
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_refund_ghost")
129
+	if err != nil {
130
+		t.Fatalf("get receipt: %v", err)
131
+	}
132
+	if !receipt.ProcessedAt.Valid {
133
+		t.Fatalf("ghost-refund receipt not marked processed: %+v", receipt)
134
+	}
135
+}
136
+
137
+// TestBillingWebhookChargeRefundedWithoutInvoiceIsNoOp locks the
138
+// standalone-refund path — a refund not linked to any invoice (e.g.,
139
+// a one-off charge refund) is a no-op for the polymorphic-invoices
140
+// surface.
141
+func TestBillingWebhookChargeRefundedWithoutInvoiceIsNoOp(t *testing.T) {
142
+	t.Parallel()
143
+	pool := dbtest.NewTestDB(t)
144
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
145
+	_ = insertOrgAvatarOrg(t, pool, ownerID, "acme")
146
+
147
+	raw, err := json.Marshal(map[string]any{
148
+		"id":       "ch_standalone",
149
+		"invoice":  "", // explicit empty
150
+		"customer": "cus_standalone",
151
+	})
152
+	if err != nil {
153
+		t.Fatalf("marshal: %v", err)
154
+	}
155
+	fake := &fakeStripeRemote{
156
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
157
+			return stripeapi.Event{
158
+				ID:   "evt_refund_standalone",
159
+				Type: stripeapi.EventType("charge.refunded"),
160
+				Data: &stripeapi.EventData{Raw: raw},
161
+			}, nil
162
+		},
163
+	}
164
+	mux := newOrgBillingMux(t, pool, ownerID, fake)
165
+	resp := postBillingWebhook(t, mux, "evt_refund_standalone")
166
+	if resp.Code != http.StatusOK {
167
+		t.Fatalf("standalone-refund status=%d body=%s", resp.Code, resp.Body.String())
168
+	}
169
+}