@@ -140,8 +140,14 @@ func (h *Handlers) applyStripeCheckoutCompleted(ctx context.Context, event strip |
| 140 | 140 | return err |
| 141 | 141 | } |
| 142 | 142 | h.recordWebhookSubject(ctx, event.ID, principal) |
| 143 | | - _, err = orgbilling.SetStripeCustomerForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principal, customerID) |
| 144 | | - return err |
| 143 | + if stale, err := h.checkStaleEvent(ctx, event, principal); err != nil || stale { |
| 144 | + return err |
| 145 | + } |
| 146 | + if _, err := orgbilling.SetStripeCustomerForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principal, customerID); err != nil { |
| 147 | + return err |
| 148 | + } |
| 149 | + h.touchLastEventAt(ctx, event, principal) |
| 150 | + return nil |
| 145 | 151 | } |
| 146 | 152 | |
| 147 | 153 | // resolvePrincipalFromCheckout walks the resolution chain for a |
@@ -186,6 +192,9 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip |
| 186 | 192 | return err |
| 187 | 193 | } |
| 188 | 194 | h.recordWebhookSubject(ctx, event.ID, principal) |
| 195 | + if stale, err := h.checkStaleEvent(ctx, event, principal); err != nil || stale { |
| 196 | + return err |
| 197 | + } |
| 189 | 198 | // PRO08 D3: if the principal already has a different Stripe |
| 190 | 199 | // subscription on file, refuse to overwrite it. A second sub |
| 191 | 200 | // for the same customer (e.g., an operator created one manually |
@@ -218,12 +227,15 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip |
| 218 | 227 | return err |
| 219 | 228 | } |
| 220 | 229 | if status == orgbilling.SubscriptionStatusCanceled || string(event.Type) == "customer.subscription.deleted" { |
| 221 | | - _, err := orgbilling.MarkCanceledForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principal, event.ID) |
| 222 | | - return err |
| 230 | + if _, err := orgbilling.MarkCanceledForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principal, event.ID); err != nil { |
| 231 | + return err |
| 232 | + } |
| 233 | + h.touchLastEventAt(ctx, event, principal) |
| 234 | + return nil |
| 223 | 235 | } |
| 224 | 236 | itemID := stripeSubscriptionItemID(sub.Items) |
| 225 | 237 | periodStart, periodEnd := stripeSubscriptionPeriod(sub.Items) |
| 226 | | - _, err = orgbilling.ApplySubscriptionSnapshotForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.PrincipalSubscriptionSnapshot{ |
| 238 | + if _, err := orgbilling.ApplySubscriptionSnapshotForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.PrincipalSubscriptionSnapshot{ |
| 227 | 239 | Principal: principal, |
| 228 | 240 | Status: status, |
| 229 | 241 | StripeSubscriptionID: strings.TrimSpace(sub.ID), |
@@ -234,8 +246,11 @@ func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event strip |
| 234 | 246 | TrialEnd: unixTime(sub.TrialEnd), |
| 235 | 247 | CanceledAt: unixTime(sub.CanceledAt), |
| 236 | 248 | LastWebhookEventID: event.ID, |
| 237 | | - }) |
| 238 | | - return err |
| 249 | + }); err != nil { |
| 250 | + return err |
| 251 | + } |
| 252 | + h.touchLastEventAt(ctx, event, principal) |
| 253 | + return nil |
| 239 | 254 | } |
| 240 | 255 | |
| 241 | 256 | // resolvePrincipalFromSubscription walks the same chain as the |
@@ -375,6 +390,9 @@ func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi. |
| 375 | 390 | return err |
| 376 | 391 | } |
| 377 | 392 | h.recordWebhookSubject(ctx, event.ID, principalState.Principal) |
| 393 | + if stale, err := h.checkStaleEvent(ctx, event, principalState.Principal); err != nil || stale { |
| 394 | + return err |
| 395 | + } |
| 378 | 396 | status, err := stripeInvoiceStatus(inv.Status) |
| 379 | 397 | if err != nil { |
| 380 | 398 | return err |
@@ -402,14 +420,17 @@ func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi. |
| 402 | 420 | switch string(event.Type) { |
| 403 | 421 | case "invoice.payment_failed": |
| 404 | 422 | graceUntil := time.Now().UTC().Add(h.d.BillingGracePeriod) |
| 405 | | - _, err := orgbilling.MarkPastDueForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principalState.Principal, graceUntil, event.ID) |
| 406 | | - return err |
| 423 | + if _, err := orgbilling.MarkPastDueForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principalState.Principal, graceUntil, event.ID); err != nil { |
| 424 | + return err |
| 425 | + } |
| 407 | 426 | case "invoice.payment_succeeded": |
| 408 | 427 | if principalState.SubscriptionStatus != orgbilling.SubscriptionStatusCanceled { |
| 409 | | - _, err := orgbilling.MarkPaymentSucceededForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principalState.Principal, event.ID) |
| 410 | | - return err |
| 428 | + if _, err := orgbilling.MarkPaymentSucceededForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, principalState.Principal, event.ID); err != nil { |
| 429 | + return err |
| 430 | + } |
| 411 | 431 | } |
| 412 | 432 | } |
| 433 | + h.touchLastEventAt(ctx, event, principalState.Principal) |
| 413 | 434 | return nil |
| 414 | 435 | } |
| 415 | 436 | |
@@ -564,6 +585,59 @@ func unixTime(ts int64) time.Time { |
| 564 | 585 | return time.Unix(ts, 0).UTC() |
| 565 | 586 | } |
| 566 | 587 | |
| 588 | +// checkStaleEvent compares the incoming Stripe event's `created` |
| 589 | +// timestamp to the principal's persisted last_event_at. Returns |
| 590 | +// stale=true when the event is older than the last applied event, |
| 591 | +// in which case the caller should return nil (the parent webhook |
| 592 | +// handler logs MarkProcessed and Stripe stops retrying). Returns |
| 593 | +// err only when the staleness query itself errored. |
| 594 | +// |
| 595 | +// PRO08 D4. Stripe doesn't guarantee delivery order across retries; |
| 596 | +// without this guard a stale subscription.updated[active] arriving |
| 597 | +// after a fresh subscription.updated[canceled] would re-activate the |
| 598 | +// principal. |
| 599 | +func (h *Handlers) checkStaleEvent(ctx context.Context, event stripeapi.Event, p orgbilling.Principal) (bool, error) { |
| 600 | + if err := p.Validate(); err != nil { |
| 601 | + return false, nil |
| 602 | + } |
| 603 | + eventAt := unixTime(event.Created) |
| 604 | + if eventAt.IsZero() { |
| 605 | + // No timestamp on event — can't make a staleness judgment. |
| 606 | + return false, nil |
| 607 | + } |
| 608 | + stale, err := orgbilling.IsBillingEventStaleForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, p, eventAt) |
| 609 | + if err != nil { |
| 610 | + h.d.Logger.WarnContext(ctx, "org billing: stale-event check failed", |
| 611 | + "event_id", event.ID, "principal", p.String(), "error", err) |
| 612 | + return false, nil |
| 613 | + } |
| 614 | + if stale { |
| 615 | + h.d.Logger.InfoContext(ctx, "org billing: dropping stale Stripe event", |
| 616 | + "event_id", event.ID, |
| 617 | + "event_type", event.Type, |
| 618 | + "event_created", eventAt, |
| 619 | + "principal", p.String()) |
| 620 | + } |
| 621 | + return stale, nil |
| 622 | +} |
| 623 | + |
| 624 | +// touchLastEventAt updates the principal's last_event_at after a |
| 625 | +// successful apply. Logs and continues on error — this is auxiliary |
| 626 | +// to the load-bearing state mutation. |
| 627 | +func (h *Handlers) touchLastEventAt(ctx context.Context, event stripeapi.Event, p orgbilling.Principal) { |
| 628 | + if err := p.Validate(); err != nil { |
| 629 | + return |
| 630 | + } |
| 631 | + eventAt := unixTime(event.Created) |
| 632 | + if eventAt.IsZero() { |
| 633 | + return |
| 634 | + } |
| 635 | + if err := orgbilling.TouchBillingLastEventAtForPrincipal(ctx, orgbilling.Deps{Pool: h.d.Pool}, p, eventAt); err != nil { |
| 636 | + h.d.Logger.WarnContext(ctx, "org billing: touch last_event_at failed", |
| 637 | + "event_id", event.ID, "principal", p.String(), "error", err) |
| 638 | + } |
| 639 | +} |
| 640 | + |
| 567 | 641 | // recordWebhookSubject persists the resolved principal on the receipt |
| 568 | 642 | // row so failed events keep their audit trail. Logs and continues on |
| 569 | 643 | // error — the subject is auxiliary; the state-mutation path is the |