@@ -186,6 +186,19 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip |
| 186 | 186 | return err |
| 187 | 187 | } |
| 188 | 188 | 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 | + } |
| 189 | 202 | // Cross-kind price-id check: if the subscription's first item |
| 190 | 203 | // price doesn't match the expected price for the resolved kind, |
| 191 | 204 | // 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 |
| 265 | 278 | // non-configured client (Pro disabled) skips the check rather than |
| 266 | 279 | // rejecting org events. PRO-disabled instances never see Pro |
| 267 | 280 | // 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 | + |
| 268 | 325 | func (h *Handlers) guardPriceKindMatch(kind orgbilling.SubjectKind, sub *stripeapi.Subscription) error { |
| 269 | 326 | teamPrice, proPrice := h.d.BillingPriceIDs() |
| 270 | 327 | // PRO08 A1: when ANY price is configured we MUST be able to |