@@ -229,13 +229,29 @@ func (h *Handlers) resolvePrincipalFromSubscription(ctx context.Context, sub *st |
| 229 | // rejecting org events. PRO-disabled instances never see Pro | 229 | // rejecting org events. PRO-disabled instances never see Pro |
| 230 | // events, so the org path is unaffected. | 230 | // events, so the org path is unaffected. |
| 231 | func (h *Handlers) guardPriceKindMatch(kind orgbilling.SubjectKind, sub *stripeapi.Subscription) error { | 231 | func (h *Handlers) guardPriceKindMatch(kind orgbilling.SubjectKind, sub *stripeapi.Subscription) error { |
| 232 | - if sub == nil || sub.Items == nil || len(sub.Items.Data) == 0 || sub.Items.Data[0] == nil || sub.Items.Data[0].Price == nil { | 232 | + teamPrice, proPrice := h.d.BillingPriceIDs() |
| 233 | - // No price on the event — nothing to validate. Subsequent | 233 | + // PRO08 A1: when ANY price is configured we MUST be able to |
| 234 | - // apply logic surfaces the missing-data error if needed. | 234 | + // inspect the event's price-id to enforce cross-kind separation. |
| | 235 | + // A subscription event with empty Items can otherwise bypass the |
| | 236 | + // guard entirely — a Pro-priced subscription with `subject_kind=org` |
| | 237 | + // metadata would silently write Team to the org-side table. Refuse |
| | 238 | + // the apply so Stripe retries (and the operator notices). |
| | 239 | + if teamPrice != "" || proPrice != "" { |
| | 240 | + if sub == nil || sub.Items == nil || len(sub.Items.Data) == 0 || sub.Items.Data[0] == nil || sub.Items.Data[0].Price == nil { |
| | 241 | + id := "" |
| | 242 | + if sub != nil { |
| | 243 | + id = strings.TrimSpace(sub.ID) |
| | 244 | + } |
| | 245 | + return fmt.Errorf("stripe subscription %q: no line items in event — refusing apply (cross-kind price guard cannot run)", id) |
| | 246 | + } |
| | 247 | + } else if sub == nil || sub.Items == nil || len(sub.Items.Data) == 0 || sub.Items.Data[0] == nil || sub.Items.Data[0].Price == nil { |
| | 248 | + // No prices configured AND no items — nothing to validate. |
| | 249 | + // The instance has billing disabled or runs Pro-only / Team- |
| | 250 | + // only without the other tier's price wired; let the apply |
| | 251 | + // flow handle the rest. |
| 235 | return nil | 252 | return nil |
| 236 | } | 253 | } |
| 237 | priceID := strings.TrimSpace(sub.Items.Data[0].Price.ID) | 254 | priceID := strings.TrimSpace(sub.Items.Data[0].Price.ID) |
| 238 | - teamPrice, proPrice := h.d.BillingPriceIDs() | | |
| 239 | switch kind { | 255 | switch kind { |
| 240 | case orgbilling.SubjectKindOrg: | 256 | case orgbilling.SubjectKindOrg: |
| 241 | if teamPrice != "" && priceID != "" && priceID != teamPrice { | 257 | if teamPrice != "" && priceID != "" && priceID != teamPrice { |