tenseleyflow/shithub / 394c6e9

Browse files

stripebilling: ProPriceID + subject-aware checkout + metadata + idempotency

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
394c6e930f79bd150a20c7b3bc35ea29d050e126
Parents
2143ef5
Tree
65efaba

2 changed files

StatusFile+-
M internal/billing/stripebilling/client.go 165 17
M internal/billing/stripebilling/client_test.go 98 0
internal/billing/stripebilling/client.gomodified
@@ -18,23 +18,44 @@ import (
1818
 )
1919
 
2020
 const (
21
-	MetadataOrgID   = "shithub_org_id"
22
-	MetadataOrgSlug = "shithub_org_slug"
21
+	// MetadataOrgID and MetadataOrgSlug are the legacy SP03 keys.
22
+	// PRO04 introduces MetadataSubjectKind and MetadataSubjectID;
23
+	// new subscriptions stamp both for forward compatibility, the
24
+	// webhook resolver falls back to the legacy keys for org rows
25
+	// created before PRO04 deployed.
26
+	MetadataOrgID        = "shithub_org_id"
27
+	MetadataOrgSlug      = "shithub_org_slug"
28
+	MetadataSubjectKind  = "shithub_subject_kind"
29
+	MetadataSubjectID    = "shithub_subject_id"
30
+	MetadataSubjectLabel = "shithub_subject_label" // human-readable, e.g. org slug or username
31
+)
32
+
33
+// SubjectKind mirrors billing.SubjectKind without taking a hard
34
+// dependency on the billing package (avoid import cycle).
35
+// Conversions live in the webhook handler.
36
+type SubjectKind string
37
+
38
+const (
39
+	SubjectKindUser SubjectKind = "user"
40
+	SubjectKindOrg  SubjectKind = "org"
2341
 )
2442
 
2543
 var (
2644
 	ErrSecretKeyRequired     = errors.New("stripe billing: secret key is required")
2745
 	ErrWebhookSecretRequired = errors.New("stripe billing: webhook secret is required")
2846
 	ErrTeamPriceRequired     = errors.New("stripe billing: team price id is required")
47
+	ErrProPriceRequired      = errors.New("stripe billing: pro price id is required")
2948
 	ErrCustomerIDRequired    = errors.New("stripe billing: customer id is required")
3049
 	ErrSubscriptionItemID    = errors.New("stripe billing: subscription item id is required")
3150
 	ErrURLRequired           = errors.New("stripe billing: redirect url is required")
51
+	ErrInvalidSubjectKind    = errors.New("stripe billing: invalid subject kind")
3252
 )
3353
 
3454
 type Config struct {
3555
 	SecretKey     string
3656
 	WebhookSecret string
3757
 	TeamPriceID   string
58
+	ProPriceID    string // PRO04: required once user-tier path is enabled
3859
 	AutomaticTax  bool
3960
 }
4061
 
@@ -50,14 +71,24 @@ type Client struct {
5071
 	stripe        *stripeapi.Client
5172
 	webhookSecret string
5273
 	teamPriceID   string
74
+	proPriceID    string
5375
 	automaticTax  bool
5476
 }
5577
 
78
+// CustomerInput carries enough subject context to populate Stripe
79
+// metadata for new customer records. The legacy `OrgID`/`OrgSlug`
80
+// pair stays for backward compatibility with SP03 callers; PRO04
81
+// callers populate `Kind` + `SubjectID` + `Label`, and the
82
+// metadata stamps both old and new keys.
5683
 type CustomerInput struct {
5784
 	OrgID   int64
5885
 	OrgSlug string
5986
 	OrgName string
6087
 	Email   string
88
+
89
+	Kind      SubjectKind // PRO04: "user" | "org"
90
+	SubjectID int64       // PRO04: user id or org id
91
+	Label     string      // PRO04: human-readable (org slug or username)
6192
 }
6293
 
6394
 type Customer struct {
@@ -71,6 +102,10 @@ type CheckoutInput struct {
71102
 	SeatCount  int64
72103
 	SuccessURL string
73104
 	CancelURL  string
105
+
106
+	Kind      SubjectKind // PRO04: routes to TeamPriceID (org) or ProPriceID (user)
107
+	SubjectID int64       // PRO04: user id or org id
108
+	Label     string      // PRO04: human-readable for metadata.shithub_subject_label
74109
 }
75110
 
76111
 type CheckoutSession struct {
@@ -98,6 +133,7 @@ func New(cfg Config) (*Client, error) {
98133
 	cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
99134
 	cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret)
100135
 	cfg.TeamPriceID = strings.TrimSpace(cfg.TeamPriceID)
136
+	cfg.ProPriceID = strings.TrimSpace(cfg.ProPriceID)
101137
 	if cfg.SecretKey == "" {
102138
 		return nil, ErrSecretKeyRequired
103139
 	}
@@ -107,28 +143,49 @@ func New(cfg Config) (*Client, error) {
107143
 	if cfg.TeamPriceID == "" {
108144
 		return nil, ErrTeamPriceRequired
109145
 	}
146
+	// ProPriceID is optional at construction (operators on the
147
+	// SP-only path don't have it). Pro-path callers will get
148
+	// ErrProPriceRequired at CheckoutSession time if they try to
149
+	// route a user-kind checkout against a client that lacks the
150
+	// price id.
110151
 	return &Client{
111152
 		stripe:        stripeapi.NewClient(cfg.SecretKey),
112153
 		webhookSecret: cfg.WebhookSecret,
113154
 		teamPriceID:   cfg.TeamPriceID,
155
+		proPriceID:    cfg.ProPriceID,
114156
 		automaticTax:  cfg.AutomaticTax,
115157
 	}, nil
116158
 }
117159
 
160
+// SupportsPro reports whether the client was configured with a Pro
161
+// price. Wiring code uses this to decide whether to register the
162
+// user-tier checkout/portal routes; refusing the routes when Pro
163
+// isn't configured keeps the operator-disabled path consistent.
164
+func (c *Client) SupportsPro() bool { return c.proPriceID != "" }
165
+
118166
 func (c *Client) CreateCustomer(ctx context.Context, in CustomerInput) (Customer, error) {
167
+	// PRO04: subject-aware customer creation. When `in.Kind` is set
168
+	// the new metadata keys + idempotency-key prefix include the
169
+	// kind; legacy SP03 callers (no Kind set) keep the org-only
170
+	// behavior so existing org customers don't get duplicated.
171
+	kind, subjectID, label, err := normalizeSubject(in.Kind, in.SubjectID, in.Label, in.OrgID, in.OrgSlug)
172
+	if err != nil {
173
+		return Customer{}, err
174
+	}
119175
 	name := strings.TrimSpace(in.OrgName)
120176
 	if name == "" {
121
-		name = strings.TrimSpace(in.OrgSlug)
177
+		name = label
122178
 	}
179
+	descriptor := fmt.Sprintf("shithub %s %s", kind, label)
123180
 	params := &stripeapi.CustomerCreateParams{
124181
 		Name:        stripeapi.String(name),
125
-		Description: stripeapi.String(fmt.Sprintf("shithub organization %s", strings.TrimSpace(in.OrgSlug))),
126
-		Metadata:    orgMetadata(in.OrgID, in.OrgSlug),
182
+		Description: stripeapi.String(descriptor),
183
+		Metadata:    subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug),
127184
 	}
128185
 	if email := strings.TrimSpace(in.Email); email != "" {
129186
 		params.Email = stripeapi.String(email)
130187
 	}
131
-	params.SetIdempotencyKey(idempotencyKey("customer", in.OrgID, "v1"))
188
+	params.SetIdempotencyKey(idempotencyKey("customer", string(kind), subjectID, "v1"))
132189
 	customer, err := c.stripe.V1Customers.Create(ctx, params)
133190
 	if err != nil {
134191
 		return Customer{}, err
@@ -149,24 +206,45 @@ func (c *Client) CreateCheckoutSession(ctx context.Context, in CheckoutInput) (C
149206
 	if in.CancelURL == "" {
150207
 		return CheckoutSession{}, fmt.Errorf("%w: cancel_url", ErrURLRequired)
151208
 	}
152
-	if in.SeatCount < 1 {
153
-		in.SeatCount = 1
209
+	kind, subjectID, label, err := normalizeSubject(in.Kind, in.SubjectID, in.Label, in.OrgID, in.OrgSlug)
210
+	if err != nil {
211
+		return CheckoutSession{}, err
212
+	}
213
+	// Route price + quantity by kind. Pro is single-seat; Team
214
+	// quantity equals the caller-provided seat count.
215
+	var priceID string
216
+	var quantity int64
217
+	switch kind {
218
+	case SubjectKindOrg:
219
+		priceID = c.teamPriceID
220
+		quantity = in.SeatCount
221
+		if quantity < 1 {
222
+			quantity = 1
223
+		}
224
+	case SubjectKindUser:
225
+		if c.proPriceID == "" {
226
+			return CheckoutSession{}, ErrProPriceRequired
227
+		}
228
+		priceID = c.proPriceID
229
+		quantity = 1
230
+	default:
231
+		return CheckoutSession{}, fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind)
154232
 	}
155
-	metadata := orgMetadata(in.OrgID, in.OrgSlug)
233
+	metadata := subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug)
156234
 	mode := string(stripeapi.CheckoutSessionModeSubscription)
157235
 	paymentMethodCollection := string(stripeapi.CheckoutSessionPaymentMethodCollectionAlways)
158236
 	billingAddressCollection := string(stripeapi.CheckoutSessionBillingAddressCollectionAuto)
159237
 	params := &stripeapi.CheckoutSessionCreateParams{
160238
 		Mode:                     stripeapi.String(mode),
161239
 		Customer:                 stripeapi.String(in.CustomerID),
162
-		ClientReferenceID:        stripeapi.String(strconv.FormatInt(in.OrgID, 10)),
240
+		ClientReferenceID:        stripeapi.String(strconv.FormatInt(subjectID, 10)),
163241
 		SuccessURL:               stripeapi.String(in.SuccessURL),
164242
 		CancelURL:                stripeapi.String(in.CancelURL),
165243
 		PaymentMethodCollection:  stripeapi.String(paymentMethodCollection),
166244
 		BillingAddressCollection: stripeapi.String(billingAddressCollection),
167245
 		LineItems: []*stripeapi.CheckoutSessionCreateLineItemParams{{
168
-			Price:    stripeapi.String(c.teamPriceID),
169
-			Quantity: stripeapi.Int64(in.SeatCount),
246
+			Price:    stripeapi.String(priceID),
247
+			Quantity: stripeapi.Int64(quantity),
170248
 		}},
171249
 		Metadata: metadata,
172250
 		SubscriptionData: &stripeapi.CheckoutSessionCreateSubscriptionDataParams{
@@ -178,7 +256,7 @@ func (c *Client) CreateCheckoutSession(ctx context.Context, in CheckoutInput) (C
178256
 			Enabled: stripeapi.Bool(true),
179257
 		}
180258
 	}
181
-	params.SetIdempotencyKey(idempotencyKey("checkout", in.OrgID, "team", strconv.FormatInt(in.SeatCount, 10)))
259
+	params.SetIdempotencyKey(idempotencyKey("checkout", string(kind), subjectID, planLabelForKind(kind), strconv.FormatInt(quantity, 10)))
182260
 	session, err := c.stripe.V1CheckoutSessions.Create(ctx, params)
183261
 	if err != nil {
184262
 		return CheckoutSession{}, err
@@ -226,11 +304,81 @@ func (c *Client) VerifyWebhook(payload []byte, signatureHeader string) (stripeap
226304
 	return webhook.ConstructEvent(payload, signatureHeader, c.webhookSecret)
227305
 }
228306
 
229
-func orgMetadata(orgID int64, orgSlug string) map[string]string {
230
-	return map[string]string{
231
-		MetadataOrgID:   strconv.FormatInt(orgID, 10),
232
-		MetadataOrgSlug: strings.TrimSpace(orgSlug),
307
+// normalizeSubject resolves the (kind, id, label) tuple from a
308
+// CheckoutInput / CustomerInput. Legacy SP03 callers populate only
309
+// OrgID/OrgSlug and zero Kind — those default to SubjectKindOrg
310
+// for backward compatibility. PRO04 callers populate Kind +
311
+// SubjectID + Label and may leave OrgID/OrgSlug zero (user kind).
312
+//
313
+// Cross-checks: if both legacy OrgID and explicit user-kind
314
+// SubjectID are set, the explicit Kind wins; the OrgID stays in
315
+// the metadata for audit but the routing key is Kind+SubjectID.
316
+func normalizeSubject(kind SubjectKind, subjectID int64, label string, orgID int64, orgSlug string) (SubjectKind, int64, string, error) {
317
+	if kind == "" {
318
+		// Legacy path: org-only.
319
+		if orgID <= 0 {
320
+			return "", 0, "", fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind)
321
+		}
322
+		return SubjectKindOrg, orgID, strings.TrimSpace(orgSlug), nil
323
+	}
324
+	if kind != SubjectKindOrg && kind != SubjectKindUser {
325
+		return "", 0, "", fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind)
326
+	}
327
+	if subjectID <= 0 {
328
+		// Fall back to OrgID for org kind when caller forgot to set
329
+		// SubjectID explicitly; tightens the API without breaking
330
+		// the SP03→PRO04 transition.
331
+		if kind == SubjectKindOrg && orgID > 0 {
332
+			subjectID = orgID
333
+		} else {
334
+			return "", 0, "", fmt.Errorf("%w: subject id required for kind %q", ErrInvalidSubjectKind, kind)
335
+		}
336
+	}
337
+	label = strings.TrimSpace(label)
338
+	if label == "" && kind == SubjectKindOrg {
339
+		label = strings.TrimSpace(orgSlug)
340
+	}
341
+	return kind, subjectID, label, nil
342
+}
343
+
344
+// subjectMetadata returns the Stripe metadata map stamped on
345
+// customers, checkout sessions, and subscription objects. Both the
346
+// new MetadataSubject* keys and the legacy MetadataOrg* keys are
347
+// emitted when the kind is org — this keeps SP03-deployed webhook
348
+// resolvers working through the transition. User-kind metadata
349
+// omits the legacy keys (no legacy resolver expected them).
350
+func subjectMetadata(kind SubjectKind, subjectID int64, label string, orgID int64, orgSlug string) map[string]string {
351
+	m := map[string]string{
352
+		MetadataSubjectKind:  string(kind),
353
+		MetadataSubjectID:    strconv.FormatInt(subjectID, 10),
354
+		MetadataSubjectLabel: label,
355
+	}
356
+	if kind == SubjectKindOrg {
357
+		if orgID == 0 {
358
+			orgID = subjectID
359
+		}
360
+		if orgSlug == "" {
361
+			orgSlug = label
362
+		}
363
+		m[MetadataOrgID] = strconv.FormatInt(orgID, 10)
364
+		m[MetadataOrgSlug] = strings.TrimSpace(orgSlug)
365
+	}
366
+	return m
367
+}
368
+
369
+// planLabelForKind returns the marketing label baked into the
370
+// idempotency key. Keeps Pro and Team checkouts from colliding on
371
+// the same idempotency string even if a single subject ID exists
372
+// on both subject_kind tables (which the schema prohibits, but
373
+// defense-in-depth).
374
+func planLabelForKind(kind SubjectKind) string {
375
+	switch kind {
376
+	case SubjectKindUser:
377
+		return "pro"
378
+	case SubjectKindOrg:
379
+		return "team"
233380
 	}
381
+	return string(kind)
234382
 }
235383
 
236384
 func idempotencyKey(parts ...any) string {
internal/billing/stripebilling/client_test.gomodified
@@ -24,6 +24,104 @@ func TestNewValidatesRequiredConfig(t *testing.T) {
2424
 	}
2525
 }
2626
 
27
+func TestSupportsProReflectsConfig(t *testing.T) {
28
+	t.Parallel()
29
+	teamOnly, err := New(Config{
30
+		SecretKey:     "sk_test_123",
31
+		WebhookSecret: "whsec_123",
32
+		TeamPriceID:   "price_team",
33
+	})
34
+	if err != nil {
35
+		t.Fatalf("New team-only: %v", err)
36
+	}
37
+	if teamOnly.SupportsPro() {
38
+		t.Errorf("SupportsPro should be false when ProPriceID empty")
39
+	}
40
+
41
+	withPro, err := New(Config{
42
+		SecretKey:     "sk_test_123",
43
+		WebhookSecret: "whsec_123",
44
+		TeamPriceID:   "price_team",
45
+		ProPriceID:    "price_pro",
46
+	})
47
+	if err != nil {
48
+		t.Fatalf("New with pro: %v", err)
49
+	}
50
+	if !withPro.SupportsPro() {
51
+		t.Errorf("SupportsPro should be true when ProPriceID set")
52
+	}
53
+}
54
+
55
+func TestNormalizeSubjectLegacyOrgOnly(t *testing.T) {
56
+	t.Parallel()
57
+	kind, id, label, err := normalizeSubject("", 0, "", 42, "acme")
58
+	if err != nil {
59
+		t.Fatalf("legacy org-only: %v", err)
60
+	}
61
+	if kind != SubjectKindOrg || id != 42 || label != "acme" {
62
+		t.Errorf("legacy normalize: kind=%s id=%d label=%q", kind, id, label)
63
+	}
64
+}
65
+
66
+func TestNormalizeSubjectExplicitUser(t *testing.T) {
67
+	t.Parallel()
68
+	kind, id, label, err := normalizeSubject(SubjectKindUser, 7, "alice", 0, "")
69
+	if err != nil {
70
+		t.Fatalf("explicit user: %v", err)
71
+	}
72
+	if kind != SubjectKindUser || id != 7 || label != "alice" {
73
+		t.Errorf("user normalize: kind=%s id=%d label=%q", kind, id, label)
74
+	}
75
+}
76
+
77
+func TestNormalizeSubjectRejectsBogusKind(t *testing.T) {
78
+	t.Parallel()
79
+	if _, _, _, err := normalizeSubject("alien", 1, "x", 0, ""); !errors.Is(err, ErrInvalidSubjectKind) {
80
+		t.Fatalf("expected ErrInvalidSubjectKind, got %v", err)
81
+	}
82
+}
83
+
84
+func TestNormalizeSubjectRequiresIDOrOrgFallback(t *testing.T) {
85
+	t.Parallel()
86
+	// User kind without an ID is invalid.
87
+	if _, _, _, err := normalizeSubject(SubjectKindUser, 0, "", 0, ""); !errors.Is(err, ErrInvalidSubjectKind) {
88
+		t.Fatalf("user without id: expected ErrInvalidSubjectKind, got %v", err)
89
+	}
90
+	// Org kind with zero SubjectID but OrgID set falls back.
91
+	kind, id, _, err := normalizeSubject(SubjectKindOrg, 0, "acme", 99, "acme")
92
+	if err != nil {
93
+		t.Fatalf("org fallback: %v", err)
94
+	}
95
+	if kind != SubjectKindOrg || id != 99 {
96
+		t.Errorf("org fallback: kind=%s id=%d", kind, id)
97
+	}
98
+}
99
+
100
+func TestSubjectMetadataOrgKindIncludesLegacyKeys(t *testing.T) {
101
+	t.Parallel()
102
+	m := subjectMetadata(SubjectKindOrg, 42, "acme", 42, "acme")
103
+	if m[MetadataSubjectKind] != "org" || m[MetadataSubjectID] != "42" {
104
+		t.Errorf("PRO04 keys missing for org: %+v", m)
105
+	}
106
+	if m[MetadataOrgID] != "42" || m[MetadataOrgSlug] != "acme" {
107
+		t.Errorf("legacy keys missing for org: %+v", m)
108
+	}
109
+}
110
+
111
+func TestSubjectMetadataUserKindOmitsLegacyOrgKeys(t *testing.T) {
112
+	t.Parallel()
113
+	m := subjectMetadata(SubjectKindUser, 7, "alice", 0, "")
114
+	if m[MetadataSubjectKind] != "user" || m[MetadataSubjectID] != "7" {
115
+		t.Errorf("PRO04 keys missing for user: %+v", m)
116
+	}
117
+	if _, ok := m[MetadataOrgID]; ok {
118
+		t.Errorf("user metadata should omit MetadataOrgID; got %+v", m)
119
+	}
120
+	if _, ok := m[MetadataOrgSlug]; ok {
121
+		t.Errorf("user metadata should omit MetadataOrgSlug; got %+v", m)
122
+	}
123
+}
124
+
27125
 func TestVerifyWebhookUsesSigningSecret(t *testing.T) {
28126
 	t.Parallel()
29127
 	client, err := New(Config{