@@ -102,6 +102,7 @@ func (h *Handlers) applyStripeCheckoutCompleted(ctx context.Context, event strip |
| 102 | 102 | if err != nil { |
| 103 | 103 | return err |
| 104 | 104 | } |
| 105 | + h.recordWebhookSubject(ctx, event.ID, principal) |
| 105 | 106 | _, err = orgbilling.SetStripeCustomerForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principal, customerID) |
| 106 | 107 | return err |
| 107 | 108 | } |
@@ -147,6 +148,7 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip |
| 147 | 148 | if err != nil { |
| 148 | 149 | return err |
| 149 | 150 | } |
| 151 | + h.recordWebhookSubject(ctx, event.ID, principal) |
| 150 | 152 | // Cross-kind price-id check: if the subscription's first item |
| 151 | 153 | // price doesn't match the expected price for the resolved kind, |
| 152 | 154 | // refuse to apply. A Pro price on an org subject (or Team on |
@@ -262,6 +264,7 @@ func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi. |
| 262 | 264 | if err != nil { |
| 263 | 265 | return err |
| 264 | 266 | } |
| 267 | + h.recordWebhookSubject(ctx, event.ID, principalState.Principal) |
| 265 | 268 | status, err := stripeInvoiceStatus(inv.Status) |
| 266 | 269 | if err != nil { |
| 267 | 270 | return err |
@@ -451,6 +454,24 @@ func unixTime(ts int64) time.Time { |
| 451 | 454 | return time.Unix(ts, 0).UTC() |
| 452 | 455 | } |
| 453 | 456 | |
| 457 | +// recordWebhookSubject persists the resolved principal on the receipt |
| 458 | +// row so failed events keep their audit trail. Logs and continues on |
| 459 | +// error — the subject is auxiliary; the state-mutation path is the |
| 460 | +// load-bearing thing. A zero principal (invalid kind / ID) is treated |
| 461 | +// as a programmer error and silently dropped: the both-or-neither |
| 462 | +// CHECK constraint on the receipt table would reject the write. |
| 463 | +func (h *Handlers) recordWebhookSubject(ctx context.Context, eventID string, p orgbilling.Principal) { |
| 464 | + if err := p.Validate(); err != nil { |
| 465 | + h.d.Logger.WarnContext(ctx, "org billing: webhook subject record skipped — invalid principal", |
| 466 | + "event_id", eventID, "error", err) |
| 467 | + return |
| 468 | + } |
| 469 | + if err := orgbilling.SetWebhookEventSubjectForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, eventID, p); err != nil { |
| 470 | + h.d.Logger.WarnContext(ctx, "org billing: webhook subject record failed", |
| 471 | + "event_id", eventID, "principal", p.String(), "error", err) |
| 472 | + } |
| 473 | +} |
| 474 | + |
| 454 | 475 | func unmarshalStripeEventObject[T any](event stripeapi.Event, out *T) error { |
| 455 | 476 | if event.Data == nil || len(event.Data.Raw) == 0 { |
| 456 | 477 | return errors.New("stripe webhook missing event data") |