tenseleyflow/shithub / 5141417

Browse files

web/orgs/billing_webhook: refuse to overwrite a bound subscription id with a different one

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
51414175e7f062791bc999a65fa40dbe59804b4f
Parents
36649c3
Tree
665cd21

1 changed file

StatusFile+-
M internal/web/handlers/orgs/billing_webhook.go 57 0
internal/web/handlers/orgs/billing_webhook.gomodified
@@ -186,6 +186,19 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip
186186
 		return err
187187
 	}
188188
 	h.recordWebhookSubject(ctx, event.ID, principal)
189
+	// PRO08 D3: if the principal already has a different Stripe
190
+	// subscription on file, refuse to overwrite it. A second sub
191
+	// for the same customer (e.g., an operator created one manually
192
+	// in the Stripe Dashboard) silently overwriting the first would
193
+	// orphan the original — Stripe keeps billing both, shithub
194
+	// tracks only the latest. Loud-fail so retries surface to the
195
+	// operator. Skip the check on subscription.deleted: that path
196
+	// reads the current sub id and clears state by design.
197
+	if string(event.Type) != "customer.subscription.deleted" {
198
+		if err := h.guardSubscriptionOverwrite(ctx, principal, &sub); err != nil {
199
+			return err
200
+		}
201
+	}
189202
 	// Cross-kind price-id check: if the subscription's first item
190203
 	// price doesn't match the expected price for the resolved kind,
191204
 	// refuse to apply. A Pro price on an org subject (or Team on
@@ -265,6 +278,50 @@ func (h *Handlers) resolvePrincipalFromSubscription(ctx context.Context, sub *st
265278
 // non-configured client (Pro disabled) skips the check rather than
266279
 // rejecting org events. PRO-disabled instances never see Pro
267280
 // events, so the org path is unaffected.
281
+// guardSubscriptionOverwrite refuses to apply a subscription event
282
+// when the principal already has a DIFFERENT subscription on file.
283
+// Stripe can hold multiple subscriptions per customer; pointing the
284
+// shithub side-state row at a second sub would orphan the first
285
+// (Stripe keeps invoicing both; shithub tracks only the latest).
286
+//
287
+// The guard reads the current state for the resolved principal. If
288
+// the persisted StripeSubscriptionID is empty or matches the
289
+// incoming sub.ID, allow. Otherwise refuse — the operator must
290
+// reconcile in the Stripe Dashboard before shithub flips.
291
+//
292
+// PRO08 D3.
293
+func (h *Handlers) guardSubscriptionOverwrite(ctx context.Context, p orgbilling.Principal, sub *stripeapi.Subscription) error {
294
+	if sub == nil {
295
+		return nil
296
+	}
297
+	incoming := strings.TrimSpace(sub.ID)
298
+	if incoming == "" {
299
+		return nil
300
+	}
301
+	deps := orgbilling.Deps{Pool: h.d.Pool}
302
+	switch p.Kind {
303
+	case orgbilling.SubjectKindOrg:
304
+		state, err := orgbilling.GetOrgBillingState(ctx, deps, p.ID)
305
+		if err != nil {
306
+			return err
307
+		}
308
+		current := strings.TrimSpace(state.StripeSubscriptionID.String)
309
+		if state.StripeSubscriptionID.Valid && current != "" && current != incoming {
310
+			return fmt.Errorf("stripe subscription: org %d already bound to subscription %q; refusing to overwrite with %q", p.ID, current, incoming)
311
+		}
312
+	case orgbilling.SubjectKindUser:
313
+		state, err := orgbilling.GetUserBillingState(ctx, deps, p.ID)
314
+		if err != nil {
315
+			return err
316
+		}
317
+		current := strings.TrimSpace(state.StripeSubscriptionID.String)
318
+		if state.StripeSubscriptionID.Valid && current != "" && current != incoming {
319
+			return fmt.Errorf("stripe subscription: user %d already bound to subscription %q; refusing to overwrite with %q", p.ID, current, incoming)
320
+		}
321
+	}
322
+	return nil
323
+}
324
+
268325
 func (h *Handlers) guardPriceKindMatch(kind orgbilling.SubjectKind, sub *stripeapi.Subscription) error {
269326
 	teamPrice, proPrice := h.d.BillingPriceIDs()
270327
 	// PRO08 A1: when ANY price is configured we MUST be able to