tenseleyflow/shithub / 28e7461

Browse files

billing: IsBillingEventStaleForPrincipal + TouchBillingLastEventAtForPrincipal

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
28e746195ef35e3093edcb73a6c44dbc7c25a4b5
Parents
24bdba9
Tree
b83950e

4 changed files

StatusFile+-
M internal/billing/billing.go 81 0
M internal/billing/queries/billing.sql 31 0
M internal/billing/sqlc/billing.sql.go 131 26
M internal/billing/sqlc/querier.go 15 0
internal/billing/billing.gomodified
@@ -317,6 +317,87 @@ func SetWebhookEventSubjectForPrincipal(ctx context.Context, deps Deps, provider
317
 	})
317
 	})
318
 }
318
 }
319
 
319
 
320
+// IsBillingEventStaleForPrincipal reports whether an incoming Stripe
321
+// event's timestamp is older than the last event we've already applied
322
+// for this principal. PRO08 D4: the handler refuses stale events so
323
+// reverse-ordered retries can't regress state (e.g., a stale
324
+// subscription.updated[active] arriving after a fresh
325
+// subscription.updated[canceled] re-activating the principal).
326
+//
327
+// Returns false when there's no prior event on file (the first event
328
+// is never stale) or when the row simply doesn't exist (defaults to
329
+// allow; the caller's own ErrNoRows path handles missing-state).
330
+func IsBillingEventStaleForPrincipal(ctx context.Context, deps Deps, p Principal, eventAt time.Time) (bool, error) {
331
+	if err := validateDeps(deps); err != nil {
332
+		return false, err
333
+	}
334
+	if err := p.Validate(); err != nil {
335
+		return false, err
336
+	}
337
+	if eventAt.IsZero() {
338
+		return false, nil
339
+	}
340
+	q := billingdb.New()
341
+	switch p.Kind {
342
+	case SubjectKindOrg:
343
+		stale, err := q.IsOrgBillingEventStale(ctx, deps.Pool, billingdb.IsOrgBillingEventStaleParams{
344
+			OrgID:   p.ID,
345
+			EventAt: pgTime(eventAt),
346
+		})
347
+		if err != nil {
348
+			if errors.Is(err, pgx.ErrNoRows) {
349
+				return false, nil
350
+			}
351
+			return false, err
352
+		}
353
+		return stale, nil
354
+	case SubjectKindUser:
355
+		stale, err := q.IsUserBillingEventStale(ctx, deps.Pool, billingdb.IsUserBillingEventStaleParams{
356
+			UserID:  p.ID,
357
+			EventAt: pgTime(eventAt),
358
+		})
359
+		if err != nil {
360
+			if errors.Is(err, pgx.ErrNoRows) {
361
+				return false, nil
362
+			}
363
+			return false, err
364
+		}
365
+		return stale, nil
366
+	}
367
+	return false, nil
368
+}
369
+
370
+// TouchBillingLastEventAtForPrincipal bumps last_event_at on the
371
+// principal's billing-state row. PRO08 D4: called after a successful
372
+// apply so subsequent staleness checks have a baseline. The query
373
+// uses GREATEST(prev, incoming) so an out-of-order-but-recent retry
374
+// doesn't regress the timestamp.
375
+func TouchBillingLastEventAtForPrincipal(ctx context.Context, deps Deps, p Principal, eventAt time.Time) error {
376
+	if err := validateDeps(deps); err != nil {
377
+		return err
378
+	}
379
+	if err := p.Validate(); err != nil {
380
+		return err
381
+	}
382
+	if eventAt.IsZero() {
383
+		return nil
384
+	}
385
+	q := billingdb.New()
386
+	switch p.Kind {
387
+	case SubjectKindOrg:
388
+		return q.TouchOrgBillingLastEventAt(ctx, deps.Pool, billingdb.TouchOrgBillingLastEventAtParams{
389
+			OrgID:   p.ID,
390
+			EventAt: pgTime(eventAt),
391
+		})
392
+	case SubjectKindUser:
393
+		return q.TouchUserBillingLastEventAt(ctx, deps.Pool, billingdb.TouchUserBillingLastEventAtParams{
394
+			UserID:  p.ID,
395
+			EventAt: pgTime(eventAt),
396
+		})
397
+	}
398
+	return nil
399
+}
400
+
320
 // ListFailedWebhookEvents is the operator query for "events we
401
 // ListFailedWebhookEvents is the operator query for "events we
321
 // received but failed to process." Returns rows whose process_error
402
 // received but failed to process." Returns rows whose process_error
322
 // is non-empty OR that have any processing_attempts but no
403
 // is non-empty OR that have any processing_attempts but no
internal/billing/queries/billing.sqlmodified
@@ -478,6 +478,37 @@ UPDATE billing_webhook_events
478
  WHERE provider = 'stripe'
478
  WHERE provider = 'stripe'
479
    AND provider_event_id = sqlc.arg(provider_event_id)::text;
479
    AND provider_event_id = sqlc.arg(provider_event_id)::text;
480
 
480
 
481
+-- name: IsOrgBillingEventStale :one
482
+-- PRO08 D4: returns true when an incoming Stripe event's timestamp
483
+-- is older than the last event we've already applied for this org.
484
+-- Stripe doesn't guarantee delivery order across retries; without
485
+-- this guard a stale `subscription.updated[active]` could re-activate
486
+-- a canceled subscription. Returns false when no prior event has
487
+-- been recorded (last_event_at IS NULL) — the first event is never
488
+-- stale.
489
+SELECT COALESCE(last_event_at > sqlc.arg(event_at)::timestamptz, false)::boolean AS stale
490
+  FROM org_billing_states
491
+ WHERE org_id = sqlc.arg(org_id)::bigint;
492
+
493
+-- name: IsUserBillingEventStale :one
494
+SELECT COALESCE(last_event_at > sqlc.arg(event_at)::timestamptz, false)::boolean AS stale
495
+  FROM user_billing_states
496
+ WHERE user_id = sqlc.arg(user_id)::bigint;
497
+
498
+-- name: TouchOrgBillingLastEventAt :exec
499
+-- PRO08 D4: bump last_event_at on successful apply. Conditional so
500
+-- a fresh apply driven by an out-of-order-but-recent retry doesn't
501
+-- regress the timestamp (GREATEST). NULL last_event_at acquires the
502
+-- incoming value.
503
+UPDATE org_billing_states
504
+   SET last_event_at = GREATEST(COALESCE(last_event_at, sqlc.arg(event_at)::timestamptz), sqlc.arg(event_at)::timestamptz)
505
+ WHERE org_id = sqlc.arg(org_id)::bigint;
506
+
507
+-- name: TouchUserBillingLastEventAt :exec
508
+UPDATE user_billing_states
509
+   SET last_event_at = GREATEST(COALESCE(last_event_at, sqlc.arg(event_at)::timestamptz), sqlc.arg(event_at)::timestamptz)
510
+ WHERE user_id = sqlc.arg(user_id)::bigint;
511
+
481
 -- name: TryAcquireWebhookEventLock :one
512
 -- name: TryAcquireWebhookEventLock :one
482
 -- PRO08 A3: transaction-scoped advisory lock keyed on the hash of
513
 -- PRO08 A3: transaction-scoped advisory lock keyed on the hash of
483
 -- the provider_event_id. Two concurrent webhook deliveries for the
514
 -- the provider_event_id. Two concurrent webhook deliveries for the
internal/billing/sqlc/billing.sql.gomodified
@@ -92,7 +92,7 @@ WITH state AS (
92
                ELSE org_billing_states.grace_until
92
                ELSE org_billing_states.grace_until
93
            END,
93
            END,
94
            updated_at = now()
94
            updated_at = now()
95
-    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
95
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
96
 ), org_update AS (
96
 ), org_update AS (
97
     UPDATE orgs
97
     UPDATE orgs
98
        SET plan = $2::org_plan,
98
        SET plan = $2::org_plan,
@@ -100,7 +100,7 @@ WITH state AS (
100
      WHERE id = $1::bigint
100
      WHERE id = $1::bigint
101
     RETURNING id
101
     RETURNING id
102
 )
102
 )
103
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
103
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
104
 `
104
 `
105
 
105
 
106
 type ApplySubscriptionSnapshotParams struct {
106
 type ApplySubscriptionSnapshotParams struct {
@@ -139,6 +139,7 @@ type ApplySubscriptionSnapshotRow struct {
139
 	LastWebhookEventID       string
139
 	LastWebhookEventID       string
140
 	CreatedAt                pgtype.Timestamptz
140
 	CreatedAt                pgtype.Timestamptz
141
 	UpdatedAt                pgtype.Timestamptz
141
 	UpdatedAt                pgtype.Timestamptz
142
+	LastEventAt              pgtype.Timestamptz
142
 }
143
 }
143
 
144
 
144
 func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error) {
145
 func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error) {
@@ -178,6 +179,7 @@ func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg Ap
178
 		&i.LastWebhookEventID,
179
 		&i.LastWebhookEventID,
179
 		&i.CreatedAt,
180
 		&i.CreatedAt,
180
 		&i.UpdatedAt,
181
 		&i.UpdatedAt,
182
+		&i.LastEventAt,
181
 	)
183
 	)
182
 	return i, err
184
 	return i, err
183
 }
185
 }
@@ -260,7 +262,7 @@ WITH state AS (
260
                ELSE user_billing_states.grace_until
262
                ELSE user_billing_states.grace_until
261
            END,
263
            END,
262
            updated_at = now()
264
            updated_at = now()
263
-    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
265
+    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
264
 ), user_update AS (
266
 ), user_update AS (
265
     UPDATE users
267
     UPDATE users
266
        SET plan = $2::user_plan,
268
        SET plan = $2::user_plan,
@@ -268,7 +270,7 @@ WITH state AS (
268
      WHERE id = $1::bigint
270
      WHERE id = $1::bigint
269
     RETURNING id
271
     RETURNING id
270
 )
272
 )
271
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
273
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
272
 `
274
 `
273
 
275
 
274
 type ApplyUserSubscriptionSnapshotParams struct {
276
 type ApplyUserSubscriptionSnapshotParams struct {
@@ -305,6 +307,7 @@ type ApplyUserSubscriptionSnapshotRow struct {
305
 	LastWebhookEventID       string
307
 	LastWebhookEventID       string
306
 	CreatedAt                pgtype.Timestamptz
308
 	CreatedAt                pgtype.Timestamptz
307
 	UpdatedAt                pgtype.Timestamptz
309
 	UpdatedAt                pgtype.Timestamptz
310
+	LastEventAt              pgtype.Timestamptz
308
 }
311
 }
309
 
312
 
310
 // Mirrors ApplySubscriptionSnapshot for orgs minus the seat columns
313
 // Mirrors ApplySubscriptionSnapshot for orgs minus the seat columns
@@ -345,6 +348,7 @@ func (q *Queries) ApplyUserSubscriptionSnapshot(ctx context.Context, db DBTX, ar
345
 		&i.LastWebhookEventID,
348
 		&i.LastWebhookEventID,
346
 		&i.CreatedAt,
349
 		&i.CreatedAt,
347
 		&i.UpdatedAt,
350
 		&i.UpdatedAt,
351
+		&i.LastEventAt,
348
 	)
352
 	)
349
 	return i, err
353
 	return i, err
350
 }
354
 }
@@ -365,7 +369,7 @@ WITH state AS (
365
            grace_until = NULL,
369
            grace_until = NULL,
366
            updated_at = now()
370
            updated_at = now()
367
      WHERE org_id = $1
371
      WHERE org_id = $1
368
-    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
372
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
369
 ), org_update AS (
373
 ), org_update AS (
370
     UPDATE orgs
374
     UPDATE orgs
371
        SET plan = state.plan,
375
        SET plan = state.plan,
@@ -374,7 +378,7 @@ WITH state AS (
374
      WHERE orgs.id = state.org_id
378
      WHERE orgs.id = state.org_id
375
     RETURNING orgs.id
379
     RETURNING orgs.id
376
 )
380
 )
377
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
381
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
378
 `
382
 `
379
 
383
 
380
 type ClearBillingLockRow struct {
384
 type ClearBillingLockRow struct {
@@ -399,6 +403,7 @@ type ClearBillingLockRow struct {
399
 	LastWebhookEventID       string
403
 	LastWebhookEventID       string
400
 	CreatedAt                pgtype.Timestamptz
404
 	CreatedAt                pgtype.Timestamptz
401
 	UpdatedAt                pgtype.Timestamptz
405
 	UpdatedAt                pgtype.Timestamptz
406
+	LastEventAt              pgtype.Timestamptz
402
 }
407
 }
403
 
408
 
404
 func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error) {
409
 func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error) {
@@ -426,6 +431,7 @@ func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (C
426
 		&i.LastWebhookEventID,
431
 		&i.LastWebhookEventID,
427
 		&i.CreatedAt,
432
 		&i.CreatedAt,
428
 		&i.UpdatedAt,
433
 		&i.UpdatedAt,
434
+		&i.LastEventAt,
429
 	)
435
 	)
430
 	return i, err
436
 	return i, err
431
 }
437
 }
@@ -446,7 +452,7 @@ WITH state AS (
446
            grace_until = NULL,
452
            grace_until = NULL,
447
            updated_at = now()
453
            updated_at = now()
448
      WHERE user_id = $1
454
      WHERE user_id = $1
449
-    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
455
+    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
450
 ), user_update AS (
456
 ), user_update AS (
451
     UPDATE users
457
     UPDATE users
452
        SET plan = state.plan,
458
        SET plan = state.plan,
@@ -455,7 +461,7 @@ WITH state AS (
455
      WHERE users.id = state.user_id
461
      WHERE users.id = state.user_id
456
     RETURNING users.id
462
     RETURNING users.id
457
 )
463
 )
458
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
464
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
459
 `
465
 `
460
 
466
 
461
 type ClearUserBillingLockRow struct {
467
 type ClearUserBillingLockRow struct {
@@ -478,6 +484,7 @@ type ClearUserBillingLockRow struct {
478
 	LastWebhookEventID       string
484
 	LastWebhookEventID       string
479
 	CreatedAt                pgtype.Timestamptz
485
 	CreatedAt                pgtype.Timestamptz
480
 	UpdatedAt                pgtype.Timestamptz
486
 	UpdatedAt                pgtype.Timestamptz
487
+	LastEventAt              pgtype.Timestamptz
481
 }
488
 }
482
 
489
 
483
 func (q *Queries) ClearUserBillingLock(ctx context.Context, db DBTX, userID int64) (ClearUserBillingLockRow, error) {
490
 func (q *Queries) ClearUserBillingLock(ctx context.Context, db DBTX, userID int64) (ClearUserBillingLockRow, error) {
@@ -503,6 +510,7 @@ func (q *Queries) ClearUserBillingLock(ctx context.Context, db DBTX, userID int6
503
 		&i.LastWebhookEventID,
510
 		&i.LastWebhookEventID,
504
 		&i.CreatedAt,
511
 		&i.CreatedAt,
505
 		&i.UpdatedAt,
512
 		&i.UpdatedAt,
513
+		&i.LastEventAt,
506
 	)
514
 	)
507
 	return i, err
515
 	return i, err
508
 }
516
 }
@@ -667,7 +675,7 @@ func (q *Queries) CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg Cr
667
 const getOrgBillingState = `-- name: GetOrgBillingState :one
675
 const getOrgBillingState = `-- name: GetOrgBillingState :one
668
 
676
 
669
 
677
 
670
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM org_billing_states WHERE org_id = $1
678
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM org_billing_states WHERE org_id = $1
671
 `
679
 `
672
 
680
 
673
 // SPDX-License-Identifier: AGPL-3.0-or-later
681
 // SPDX-License-Identifier: AGPL-3.0-or-later
@@ -697,12 +705,13 @@ func (q *Queries) GetOrgBillingState(ctx context.Context, db DBTX, orgID int64)
697
 		&i.LastWebhookEventID,
705
 		&i.LastWebhookEventID,
698
 		&i.CreatedAt,
706
 		&i.CreatedAt,
699
 		&i.UpdatedAt,
707
 		&i.UpdatedAt,
708
+		&i.LastEventAt,
700
 	)
709
 	)
701
 	return i, err
710
 	return i, err
702
 }
711
 }
703
 
712
 
704
 const getOrgBillingStateByStripeCustomer = `-- name: GetOrgBillingStateByStripeCustomer :one
713
 const getOrgBillingStateByStripeCustomer = `-- name: GetOrgBillingStateByStripeCustomer :one
705
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM org_billing_states
714
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM org_billing_states
706
 WHERE provider = 'stripe'
715
 WHERE provider = 'stripe'
707
   AND stripe_customer_id = $1
716
   AND stripe_customer_id = $1
708
 `
717
 `
@@ -732,12 +741,13 @@ func (q *Queries) GetOrgBillingStateByStripeCustomer(ctx context.Context, db DBT
732
 		&i.LastWebhookEventID,
741
 		&i.LastWebhookEventID,
733
 		&i.CreatedAt,
742
 		&i.CreatedAt,
734
 		&i.UpdatedAt,
743
 		&i.UpdatedAt,
744
+		&i.LastEventAt,
735
 	)
745
 	)
736
 	return i, err
746
 	return i, err
737
 }
747
 }
738
 
748
 
739
 const getOrgBillingStateByStripeSubscription = `-- name: GetOrgBillingStateByStripeSubscription :one
749
 const getOrgBillingStateByStripeSubscription = `-- name: GetOrgBillingStateByStripeSubscription :one
740
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM org_billing_states
750
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM org_billing_states
741
 WHERE provider = 'stripe'
751
 WHERE provider = 'stripe'
742
   AND stripe_subscription_id = $1
752
   AND stripe_subscription_id = $1
743
 `
753
 `
@@ -767,13 +777,14 @@ func (q *Queries) GetOrgBillingStateByStripeSubscription(ctx context.Context, db
767
 		&i.LastWebhookEventID,
777
 		&i.LastWebhookEventID,
768
 		&i.CreatedAt,
778
 		&i.CreatedAt,
769
 		&i.UpdatedAt,
779
 		&i.UpdatedAt,
780
+		&i.LastEventAt,
770
 	)
781
 	)
771
 	return i, err
782
 	return i, err
772
 }
783
 }
773
 
784
 
774
 const getUserBillingState = `-- name: GetUserBillingState :one
785
 const getUserBillingState = `-- name: GetUserBillingState :one
775
 
786
 
776
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM user_billing_states WHERE user_id = $1
787
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM user_billing_states WHERE user_id = $1
777
 `
788
 `
778
 
789
 
779
 // ─── user_billing_states (PRO03) ──────────────────────────────────
790
 // ─── user_billing_states (PRO03) ──────────────────────────────────
@@ -800,12 +811,13 @@ func (q *Queries) GetUserBillingState(ctx context.Context, db DBTX, userID int64
800
 		&i.LastWebhookEventID,
811
 		&i.LastWebhookEventID,
801
 		&i.CreatedAt,
812
 		&i.CreatedAt,
802
 		&i.UpdatedAt,
813
 		&i.UpdatedAt,
814
+		&i.LastEventAt,
803
 	)
815
 	)
804
 	return i, err
816
 	return i, err
805
 }
817
 }
806
 
818
 
807
 const getUserBillingStateByStripeCustomer = `-- name: GetUserBillingStateByStripeCustomer :one
819
 const getUserBillingStateByStripeCustomer = `-- name: GetUserBillingStateByStripeCustomer :one
808
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM user_billing_states
820
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM user_billing_states
809
 WHERE provider = 'stripe'
821
 WHERE provider = 'stripe'
810
   AND stripe_customer_id = $1
822
   AND stripe_customer_id = $1
811
 `
823
 `
@@ -833,12 +845,13 @@ func (q *Queries) GetUserBillingStateByStripeCustomer(ctx context.Context, db DB
833
 		&i.LastWebhookEventID,
845
 		&i.LastWebhookEventID,
834
 		&i.CreatedAt,
846
 		&i.CreatedAt,
835
 		&i.UpdatedAt,
847
 		&i.UpdatedAt,
848
+		&i.LastEventAt,
836
 	)
849
 	)
837
 	return i, err
850
 	return i, err
838
 }
851
 }
839
 
852
 
840
 const getUserBillingStateByStripeSubscription = `-- name: GetUserBillingStateByStripeSubscription :one
853
 const getUserBillingStateByStripeSubscription = `-- name: GetUserBillingStateByStripeSubscription :one
841
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM user_billing_states
854
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM user_billing_states
842
 WHERE provider = 'stripe'
855
 WHERE provider = 'stripe'
843
   AND stripe_subscription_id = $1
856
   AND stripe_subscription_id = $1
844
 `
857
 `
@@ -866,6 +879,7 @@ func (q *Queries) GetUserBillingStateByStripeSubscription(ctx context.Context, d
866
 		&i.LastWebhookEventID,
879
 		&i.LastWebhookEventID,
867
 		&i.CreatedAt,
880
 		&i.CreatedAt,
868
 		&i.UpdatedAt,
881
 		&i.UpdatedAt,
882
+		&i.LastEventAt,
869
 	)
883
 	)
870
 	return i, err
884
 	return i, err
871
 }
885
 }
@@ -896,6 +910,49 @@ func (q *Queries) GetWebhookEventReceipt(ctx context.Context, db DBTX, providerE
896
 	return i, err
910
 	return i, err
897
 }
911
 }
898
 
912
 
913
+const isOrgBillingEventStale = `-- name: IsOrgBillingEventStale :one
914
+SELECT COALESCE(last_event_at > $1::timestamptz, false)::boolean AS stale
915
+  FROM org_billing_states
916
+ WHERE org_id = $2::bigint
917
+`
918
+
919
+type IsOrgBillingEventStaleParams struct {
920
+	EventAt pgtype.Timestamptz
921
+	OrgID   int64
922
+}
923
+
924
+// PRO08 D4: returns true when an incoming Stripe event's timestamp
925
+// is older than the last event we've already applied for this org.
926
+// Stripe doesn't guarantee delivery order across retries; without
927
+// this guard a stale `subscription.updated[active]` could re-activate
928
+// a canceled subscription. Returns false when no prior event has
929
+// been recorded (last_event_at IS NULL) — the first event is never
930
+// stale.
931
+func (q *Queries) IsOrgBillingEventStale(ctx context.Context, db DBTX, arg IsOrgBillingEventStaleParams) (bool, error) {
932
+	row := db.QueryRow(ctx, isOrgBillingEventStale, arg.EventAt, arg.OrgID)
933
+	var stale bool
934
+	err := row.Scan(&stale)
935
+	return stale, err
936
+}
937
+
938
+const isUserBillingEventStale = `-- name: IsUserBillingEventStale :one
939
+SELECT COALESCE(last_event_at > $1::timestamptz, false)::boolean AS stale
940
+  FROM user_billing_states
941
+ WHERE user_id = $2::bigint
942
+`
943
+
944
+type IsUserBillingEventStaleParams struct {
945
+	EventAt pgtype.Timestamptz
946
+	UserID  int64
947
+}
948
+
949
+func (q *Queries) IsUserBillingEventStale(ctx context.Context, db DBTX, arg IsUserBillingEventStaleParams) (bool, error) {
950
+	row := db.QueryRow(ctx, isUserBillingEventStale, arg.EventAt, arg.UserID)
951
+	var stale bool
952
+	err := row.Scan(&stale)
953
+	return stale, err
954
+}
955
+
899
 const listFailedWebhookEvents = `-- name: ListFailedWebhookEvents :many
956
 const listFailedWebhookEvents = `-- name: ListFailedWebhookEvents :many
900
 SELECT id, provider, provider_event_id, event_type, api_version,
957
 SELECT id, provider, provider_event_id, event_type, api_version,
901
        received_at, processed_at, processing_attempts, process_error,
958
        received_at, processed_at, processing_attempts, process_error,
@@ -1137,7 +1194,7 @@ WITH state AS (
1137
            last_webhook_event_id = $1::text,
1194
            last_webhook_event_id = $1::text,
1138
            updated_at = now()
1195
            updated_at = now()
1139
      WHERE org_id = $2::bigint
1196
      WHERE org_id = $2::bigint
1140
-    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1197
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1141
 ), org_update AS (
1198
 ), org_update AS (
1142
     UPDATE orgs
1199
     UPDATE orgs
1143
        SET plan = 'free',
1200
        SET plan = 'free',
@@ -1145,7 +1202,7 @@ WITH state AS (
1145
      WHERE id = $2::bigint
1202
      WHERE id = $2::bigint
1146
     RETURNING id
1203
     RETURNING id
1147
 )
1204
 )
1148
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
1205
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
1149
 `
1206
 `
1150
 
1207
 
1151
 type MarkCanceledParams struct {
1208
 type MarkCanceledParams struct {
@@ -1175,6 +1232,7 @@ type MarkCanceledRow struct {
1175
 	LastWebhookEventID       string
1232
 	LastWebhookEventID       string
1176
 	CreatedAt                pgtype.Timestamptz
1233
 	CreatedAt                pgtype.Timestamptz
1177
 	UpdatedAt                pgtype.Timestamptz
1234
 	UpdatedAt                pgtype.Timestamptz
1235
+	LastEventAt              pgtype.Timestamptz
1178
 }
1236
 }
1179
 
1237
 
1180
 func (q *Queries) MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error) {
1238
 func (q *Queries) MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error) {
@@ -1202,6 +1260,7 @@ func (q *Queries) MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledPar
1202
 		&i.LastWebhookEventID,
1260
 		&i.LastWebhookEventID,
1203
 		&i.CreatedAt,
1261
 		&i.CreatedAt,
1204
 		&i.UpdatedAt,
1262
 		&i.UpdatedAt,
1263
+		&i.LastEventAt,
1205
 	)
1264
 	)
1206
 	return i, err
1265
 	return i, err
1207
 }
1266
 }
@@ -1216,7 +1275,7 @@ UPDATE org_billing_states
1216
        last_webhook_event_id = $2::text,
1275
        last_webhook_event_id = $2::text,
1217
        updated_at = now()
1276
        updated_at = now()
1218
  WHERE org_id = $3::bigint
1277
  WHERE org_id = $3::bigint
1219
-RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1278
+RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1220
 `
1279
 `
1221
 
1280
 
1222
 type MarkPastDueParams struct {
1281
 type MarkPastDueParams struct {
@@ -1250,6 +1309,7 @@ func (q *Queries) MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParam
1250
 		&i.LastWebhookEventID,
1309
 		&i.LastWebhookEventID,
1251
 		&i.CreatedAt,
1310
 		&i.CreatedAt,
1252
 		&i.UpdatedAt,
1311
 		&i.UpdatedAt,
1312
+		&i.LastEventAt,
1253
 	)
1313
 	)
1254
 	return i, err
1314
 	return i, err
1255
 }
1315
 }
@@ -1275,7 +1335,7 @@ WITH state AS (
1275
            last_webhook_event_id = $1::text,
1335
            last_webhook_event_id = $1::text,
1276
            updated_at = now()
1336
            updated_at = now()
1277
      WHERE org_id = $2::bigint
1337
      WHERE org_id = $2::bigint
1278
-    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1338
+    RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1279
 ), org_update AS (
1339
 ), org_update AS (
1280
     UPDATE orgs
1340
     UPDATE orgs
1281
        SET plan = state.plan,
1341
        SET plan = state.plan,
@@ -1284,7 +1344,7 @@ WITH state AS (
1284
      WHERE orgs.id = state.org_id
1344
      WHERE orgs.id = state.org_id
1285
     RETURNING orgs.id
1345
     RETURNING orgs.id
1286
 )
1346
 )
1287
-SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
1347
+SELECT org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
1288
 `
1348
 `
1289
 
1349
 
1290
 type MarkPaymentSucceededParams struct {
1350
 type MarkPaymentSucceededParams struct {
@@ -1314,6 +1374,7 @@ type MarkPaymentSucceededRow struct {
1314
 	LastWebhookEventID       string
1374
 	LastWebhookEventID       string
1315
 	CreatedAt                pgtype.Timestamptz
1375
 	CreatedAt                pgtype.Timestamptz
1316
 	UpdatedAt                pgtype.Timestamptz
1376
 	UpdatedAt                pgtype.Timestamptz
1377
+	LastEventAt              pgtype.Timestamptz
1317
 }
1378
 }
1318
 
1379
 
1319
 func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPaymentSucceededParams) (MarkPaymentSucceededRow, error) {
1380
 func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPaymentSucceededParams) (MarkPaymentSucceededRow, error) {
@@ -1341,6 +1402,7 @@ func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPay
1341
 		&i.LastWebhookEventID,
1402
 		&i.LastWebhookEventID,
1342
 		&i.CreatedAt,
1403
 		&i.CreatedAt,
1343
 		&i.UpdatedAt,
1404
 		&i.UpdatedAt,
1405
+		&i.LastEventAt,
1344
 	)
1406
 	)
1345
 	return i, err
1407
 	return i, err
1346
 }
1408
 }
@@ -1358,7 +1420,7 @@ WITH state AS (
1358
            last_webhook_event_id = $1::text,
1420
            last_webhook_event_id = $1::text,
1359
            updated_at = now()
1421
            updated_at = now()
1360
      WHERE user_id = $2::bigint
1422
      WHERE user_id = $2::bigint
1361
-    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1423
+    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1362
 ), user_update AS (
1424
 ), user_update AS (
1363
     UPDATE users
1425
     UPDATE users
1364
        SET plan = 'free',
1426
        SET plan = 'free',
@@ -1366,7 +1428,7 @@ WITH state AS (
1366
      WHERE id = $2::bigint
1428
      WHERE id = $2::bigint
1367
     RETURNING id
1429
     RETURNING id
1368
 )
1430
 )
1369
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
1431
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
1370
 `
1432
 `
1371
 
1433
 
1372
 type MarkUserCanceledParams struct {
1434
 type MarkUserCanceledParams struct {
@@ -1394,6 +1456,7 @@ type MarkUserCanceledRow struct {
1394
 	LastWebhookEventID       string
1456
 	LastWebhookEventID       string
1395
 	CreatedAt                pgtype.Timestamptz
1457
 	CreatedAt                pgtype.Timestamptz
1396
 	UpdatedAt                pgtype.Timestamptz
1458
 	UpdatedAt                pgtype.Timestamptz
1459
+	LastEventAt              pgtype.Timestamptz
1397
 }
1460
 }
1398
 
1461
 
1399
 func (q *Queries) MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCanceledParams) (MarkUserCanceledRow, error) {
1462
 func (q *Queries) MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCanceledParams) (MarkUserCanceledRow, error) {
@@ -1419,6 +1482,7 @@ func (q *Queries) MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCan
1419
 		&i.LastWebhookEventID,
1482
 		&i.LastWebhookEventID,
1420
 		&i.CreatedAt,
1483
 		&i.CreatedAt,
1421
 		&i.UpdatedAt,
1484
 		&i.UpdatedAt,
1485
+		&i.LastEventAt,
1422
 	)
1486
 	)
1423
 	return i, err
1487
 	return i, err
1424
 }
1488
 }
@@ -1433,7 +1497,7 @@ UPDATE user_billing_states
1433
        last_webhook_event_id = $2::text,
1497
        last_webhook_event_id = $2::text,
1434
        updated_at = now()
1498
        updated_at = now()
1435
  WHERE user_id = $3::bigint
1499
  WHERE user_id = $3::bigint
1436
-RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1500
+RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1437
 `
1501
 `
1438
 
1502
 
1439
 type MarkUserPastDueParams struct {
1503
 type MarkUserPastDueParams struct {
@@ -1465,6 +1529,7 @@ func (q *Queries) MarkUserPastDue(ctx context.Context, db DBTX, arg MarkUserPast
1465
 		&i.LastWebhookEventID,
1529
 		&i.LastWebhookEventID,
1466
 		&i.CreatedAt,
1530
 		&i.CreatedAt,
1467
 		&i.UpdatedAt,
1531
 		&i.UpdatedAt,
1532
+		&i.LastEventAt,
1468
 	)
1533
 	)
1469
 	return i, err
1534
 	return i, err
1470
 }
1535
 }
@@ -1490,7 +1555,7 @@ WITH state AS (
1490
            last_webhook_event_id = $1::text,
1555
            last_webhook_event_id = $1::text,
1491
            updated_at = now()
1556
            updated_at = now()
1492
      WHERE user_id = $2::bigint
1557
      WHERE user_id = $2::bigint
1493
-    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1558
+    RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1494
 ), user_update AS (
1559
 ), user_update AS (
1495
     UPDATE users
1560
     UPDATE users
1496
        SET plan = state.plan,
1561
        SET plan = state.plan,
@@ -1499,7 +1564,7 @@ WITH state AS (
1499
      WHERE users.id = state.user_id
1564
      WHERE users.id = state.user_id
1500
     RETURNING users.id
1565
     RETURNING users.id
1501
 )
1566
 )
1502
-SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at FROM state
1567
+SELECT user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at FROM state
1503
 `
1568
 `
1504
 
1569
 
1505
 type MarkUserPaymentSucceededParams struct {
1570
 type MarkUserPaymentSucceededParams struct {
@@ -1527,6 +1592,7 @@ type MarkUserPaymentSucceededRow struct {
1527
 	LastWebhookEventID       string
1592
 	LastWebhookEventID       string
1528
 	CreatedAt                pgtype.Timestamptz
1593
 	CreatedAt                pgtype.Timestamptz
1529
 	UpdatedAt                pgtype.Timestamptz
1594
 	UpdatedAt                pgtype.Timestamptz
1595
+	LastEventAt              pgtype.Timestamptz
1530
 }
1596
 }
1531
 
1597
 
1532
 func (q *Queries) MarkUserPaymentSucceeded(ctx context.Context, db DBTX, arg MarkUserPaymentSucceededParams) (MarkUserPaymentSucceededRow, error) {
1598
 func (q *Queries) MarkUserPaymentSucceeded(ctx context.Context, db DBTX, arg MarkUserPaymentSucceededParams) (MarkUserPaymentSucceededRow, error) {
@@ -1552,6 +1618,7 @@ func (q *Queries) MarkUserPaymentSucceeded(ctx context.Context, db DBTX, arg Mar
1552
 		&i.LastWebhookEventID,
1618
 		&i.LastWebhookEventID,
1553
 		&i.CreatedAt,
1619
 		&i.CreatedAt,
1554
 		&i.UpdatedAt,
1620
 		&i.UpdatedAt,
1621
+		&i.LastEventAt,
1555
 	)
1622
 	)
1556
 	return i, err
1623
 	return i, err
1557
 }
1624
 }
@@ -1627,7 +1694,7 @@ ON CONFLICT (org_id) DO UPDATE
1627
    SET stripe_customer_id = EXCLUDED.stripe_customer_id,
1694
    SET stripe_customer_id = EXCLUDED.stripe_customer_id,
1628
        provider = 'stripe',
1695
        provider = 'stripe',
1629
        updated_at = now()
1696
        updated_at = now()
1630
-RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1697
+RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1631
 `
1698
 `
1632
 
1699
 
1633
 type SetStripeCustomerParams struct {
1700
 type SetStripeCustomerParams struct {
@@ -1660,6 +1727,7 @@ func (q *Queries) SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeC
1660
 		&i.LastWebhookEventID,
1727
 		&i.LastWebhookEventID,
1661
 		&i.CreatedAt,
1728
 		&i.CreatedAt,
1662
 		&i.UpdatedAt,
1729
 		&i.UpdatedAt,
1730
+		&i.LastEventAt,
1663
 	)
1731
 	)
1664
 	return i, err
1732
 	return i, err
1665
 }
1733
 }
@@ -1671,7 +1739,7 @@ ON CONFLICT (user_id) DO UPDATE
1671
    SET stripe_customer_id = EXCLUDED.stripe_customer_id,
1739
    SET stripe_customer_id = EXCLUDED.stripe_customer_id,
1672
        provider = 'stripe',
1740
        provider = 'stripe',
1673
        updated_at = now()
1741
        updated_at = now()
1674
-RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at
1742
+RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at, last_event_at
1675
 `
1743
 `
1676
 
1744
 
1677
 type SetUserStripeCustomerParams struct {
1745
 type SetUserStripeCustomerParams struct {
@@ -1702,6 +1770,7 @@ func (q *Queries) SetUserStripeCustomer(ctx context.Context, db DBTX, arg SetUse
1702
 		&i.LastWebhookEventID,
1770
 		&i.LastWebhookEventID,
1703
 		&i.CreatedAt,
1771
 		&i.CreatedAt,
1704
 		&i.UpdatedAt,
1772
 		&i.UpdatedAt,
1773
+		&i.LastEventAt,
1705
 	)
1774
 	)
1706
 	return i, err
1775
 	return i, err
1707
 }
1776
 }
@@ -1730,6 +1799,42 @@ func (q *Queries) SetWebhookEventSubject(ctx context.Context, db DBTX, arg SetWe
1730
 	return err
1799
 	return err
1731
 }
1800
 }
1732
 
1801
 
1802
+const touchOrgBillingLastEventAt = `-- name: TouchOrgBillingLastEventAt :exec
1803
+UPDATE org_billing_states
1804
+   SET last_event_at = GREATEST(COALESCE(last_event_at, $1::timestamptz), $1::timestamptz)
1805
+ WHERE org_id = $2::bigint
1806
+`
1807
+
1808
+type TouchOrgBillingLastEventAtParams struct {
1809
+	EventAt pgtype.Timestamptz
1810
+	OrgID   int64
1811
+}
1812
+
1813
+// PRO08 D4: bump last_event_at on successful apply. Conditional so
1814
+// a fresh apply driven by an out-of-order-but-recent retry doesn't
1815
+// regress the timestamp (GREATEST). NULL last_event_at acquires the
1816
+// incoming value.
1817
+func (q *Queries) TouchOrgBillingLastEventAt(ctx context.Context, db DBTX, arg TouchOrgBillingLastEventAtParams) error {
1818
+	_, err := db.Exec(ctx, touchOrgBillingLastEventAt, arg.EventAt, arg.OrgID)
1819
+	return err
1820
+}
1821
+
1822
+const touchUserBillingLastEventAt = `-- name: TouchUserBillingLastEventAt :exec
1823
+UPDATE user_billing_states
1824
+   SET last_event_at = GREATEST(COALESCE(last_event_at, $1::timestamptz), $1::timestamptz)
1825
+ WHERE user_id = $2::bigint
1826
+`
1827
+
1828
+type TouchUserBillingLastEventAtParams struct {
1829
+	EventAt pgtype.Timestamptz
1830
+	UserID  int64
1831
+}
1832
+
1833
+func (q *Queries) TouchUserBillingLastEventAt(ctx context.Context, db DBTX, arg TouchUserBillingLastEventAtParams) error {
1834
+	_, err := db.Exec(ctx, touchUserBillingLastEventAt, arg.EventAt, arg.UserID)
1835
+	return err
1836
+}
1837
+
1733
 const tryAcquireWebhookEventLock = `-- name: TryAcquireWebhookEventLock :one
1838
 const tryAcquireWebhookEventLock = `-- name: TryAcquireWebhookEventLock :one
1734
 SELECT pg_try_advisory_xact_lock(hashtext($1)::bigint) AS acquired
1839
 SELECT pg_try_advisory_xact_lock(hashtext($1)::bigint) AS acquired
1735
 `
1840
 `
internal/billing/sqlc/querier.gomodified
@@ -34,6 +34,15 @@ type Querier interface {
34
 	GetUserBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (UserBillingState, error)
34
 	GetUserBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (UserBillingState, error)
35
 	GetUserBillingStateByStripeSubscription(ctx context.Context, db DBTX, stripeSubscriptionID pgtype.Text) (UserBillingState, error)
35
 	GetUserBillingStateByStripeSubscription(ctx context.Context, db DBTX, stripeSubscriptionID pgtype.Text) (UserBillingState, error)
36
 	GetWebhookEventReceipt(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
36
 	GetWebhookEventReceipt(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
37
+	// PRO08 D4: returns true when an incoming Stripe event's timestamp
38
+	// is older than the last event we've already applied for this org.
39
+	// Stripe doesn't guarantee delivery order across retries; without
40
+	// this guard a stale `subscription.updated[active]` could re-activate
41
+	// a canceled subscription. Returns false when no prior event has
42
+	// been recorded (last_event_at IS NULL) — the first event is never
43
+	// stale.
44
+	IsOrgBillingEventStale(ctx context.Context, db DBTX, arg IsOrgBillingEventStaleParams) (bool, error)
45
+	IsUserBillingEventStale(ctx context.Context, db DBTX, arg IsUserBillingEventStaleParams) (bool, error)
37
 	// Operator query for "events we received but failed to process."
46
 	// Operator query for "events we received but failed to process."
38
 	// A row is "failed" when it has a non-empty process_error OR when
47
 	// A row is "failed" when it has a non-empty process_error OR when
39
 	// it has never been processed (processed_at NULL) and has at least
48
 	// it has never been processed (processed_at NULL) and has at least
@@ -67,6 +76,12 @@ type Querier interface {
67
 	// subsequent apply fails. Migration 0075's CHECK constraint enforces
76
 	// subsequent apply fails. Migration 0075's CHECK constraint enforces
68
 	// both-or-neither; callers must pass a non-zero subject.
77
 	// both-or-neither; callers must pass a non-zero subject.
69
 	SetWebhookEventSubject(ctx context.Context, db DBTX, arg SetWebhookEventSubjectParams) error
78
 	SetWebhookEventSubject(ctx context.Context, db DBTX, arg SetWebhookEventSubjectParams) error
79
+	// PRO08 D4: bump last_event_at on successful apply. Conditional so
80
+	// a fresh apply driven by an out-of-order-but-recent retry doesn't
81
+	// regress the timestamp (GREATEST). NULL last_event_at acquires the
82
+	// incoming value.
83
+	TouchOrgBillingLastEventAt(ctx context.Context, db DBTX, arg TouchOrgBillingLastEventAtParams) error
84
+	TouchUserBillingLastEventAt(ctx context.Context, db DBTX, arg TouchUserBillingLastEventAtParams) error
70
 	// PRO08 A3: transaction-scoped advisory lock keyed on the hash of
85
 	// PRO08 A3: transaction-scoped advisory lock keyed on the hash of
71
 	// the provider_event_id. Two concurrent webhook deliveries for the
86
 	// the provider_event_id. Two concurrent webhook deliveries for the
72
 	// same event_id race past CreateWebhookEventReceipt before either has
87
 	// same event_id race past CreateWebhookEventReceipt before either has