tenseleyflow/shithub / 08109a4

Browse files

billing/queries: user billing state surface + polymorphic invoice helpers

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
08109a4fb2d48afe01aabf7bddd2fd1651d96e67
Parents
d3a03d3
Tree
9bfd600

4 changed files

StatusFile+-
M internal/billing/billing.go 5 2
M internal/billing/queries/billing.sql 216 1
M internal/billing/sqlc/billing.sql.go 675 10
M internal/billing/sqlc/querier.go 27 0
internal/billing/billing.gomodified
@@ -328,8 +328,11 @@ func ListInvoicesForOrg(ctx context.Context, deps Deps, orgID int64, limit int32
328
 		limit = 10
328
 		limit = 10
329
 	}
329
 	}
330
 	return billingdb.New().ListInvoicesForOrg(ctx, deps.Pool, billingdb.ListInvoicesForOrgParams{
330
 	return billingdb.New().ListInvoicesForOrg(ctx, deps.Pool, billingdb.ListInvoicesForOrgParams{
331
-		OrgID: orgID,
331
+		// SubjectID equals OrgID by the billing_invoices_org_id_matches_subject
332
-		Limit: limit,
332
+		// CHECK constraint added in migration 0074. The polymorphic shape lets
333
+		// PRO04+ callers reuse this query without a fork.
334
+		SubjectID: orgID,
335
+		Limit:     limit,
333
 	})
336
 	})
334
 }
337
 }
335
 
338
 
internal/billing/queries/billing.sqlmodified
@@ -243,8 +243,15 @@ WHERE org_id = $1
243
 -- ─── billing_invoices ──────────────────────────────────────────────
243
 -- ─── billing_invoices ──────────────────────────────────────────────
244
 
244
 
245
 -- name: UpsertInvoice :one
245
 -- name: UpsertInvoice :one
246
+-- PRO03: writes both legacy `org_id` and polymorphic
247
+-- `(subject_kind, subject_id)`. Callers continue to bind org_id only;
248
+-- the subject columns are derived. After PRO04 migrates all callers
249
+-- to the polymorphic shape, a follow-up migration drops `org_id` and
250
+-- this query loses the legacy column from its INSERT list.
246
 INSERT INTO billing_invoices (
251
 INSERT INTO billing_invoices (
247
     org_id,
252
     org_id,
253
+    subject_kind,
254
+    subject_id,
248
     provider,
255
     provider,
249
     stripe_invoice_id,
256
     stripe_invoice_id,
250
     stripe_customer_id,
257
     stripe_customer_id,
@@ -264,6 +271,8 @@ INSERT INTO billing_invoices (
264
     voided_at
271
     voided_at
265
 )
272
 )
266
 VALUES (
273
 VALUES (
274
+    sqlc.arg(org_id)::bigint,
275
+    'org'::billing_subject_kind,
267
     sqlc.arg(org_id)::bigint,
276
     sqlc.arg(org_id)::bigint,
268
     'stripe',
277
     'stripe',
269
     sqlc.arg(stripe_invoice_id)::text,
278
     sqlc.arg(stripe_invoice_id)::text,
@@ -304,11 +313,26 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
304
 RETURNING *;
313
 RETURNING *;
305
 
314
 
306
 -- name: ListInvoicesForOrg :many
315
 -- name: ListInvoicesForOrg :many
316
+-- PRO03: filters on the polymorphic subject columns so the index
317
+-- billing_invoices_subject_created_idx services this query. The
318
+-- legacy `org_id` column is kept populated by UpsertInvoice for the
319
+-- transitional window; this query no longer reads it.
307
 SELECT * FROM billing_invoices
320
 SELECT * FROM billing_invoices
308
-WHERE org_id = $1
321
+WHERE subject_kind = 'org' AND subject_id = $1
309
 ORDER BY created_at DESC, id DESC
322
 ORDER BY created_at DESC, id DESC
310
 LIMIT $2;
323
 LIMIT $2;
311
 
324
 
325
+-- name: ListInvoicesForSubject :many
326
+-- Polymorphic invoice listing for PRO04+ callers. The org-flavored
327
+-- ListInvoicesForOrg above is the same query with subject_kind
328
+-- hard-coded; this surface lets a user-side caller pass kind='user'
329
+-- without forking the helper.
330
+SELECT * FROM billing_invoices
331
+WHERE subject_kind = sqlc.arg(subject_kind)::billing_subject_kind
332
+  AND subject_id = sqlc.arg(subject_id)::bigint
333
+ORDER BY created_at DESC, id DESC
334
+LIMIT sqlc.arg(lim)::integer;
335
+
312
 -- ─── billing_webhook_events ────────────────────────────────────────
336
 -- ─── billing_webhook_events ────────────────────────────────────────
313
 
337
 
314
 -- name: CreateWebhookEventReceipt :one
338
 -- name: CreateWebhookEventReceipt :one
@@ -350,3 +374,194 @@ UPDATE billing_webhook_events
350
  WHERE provider = 'stripe'
374
  WHERE provider = 'stripe'
351
    AND provider_event_id = $1
375
    AND provider_event_id = $1
352
 RETURNING *;
376
 RETURNING *;
377
+
378
+-- ─── user_billing_states (PRO03) ──────────────────────────────────
379
+
380
+-- name: GetUserBillingState :one
381
+SELECT * FROM user_billing_states WHERE user_id = $1;
382
+
383
+-- name: GetUserBillingStateByStripeCustomer :one
384
+SELECT * FROM user_billing_states
385
+WHERE provider = 'stripe'
386
+  AND stripe_customer_id = $1;
387
+
388
+-- name: GetUserBillingStateByStripeSubscription :one
389
+SELECT * FROM user_billing_states
390
+WHERE provider = 'stripe'
391
+  AND stripe_subscription_id = $1;
392
+
393
+-- name: SetUserStripeCustomer :one
394
+INSERT INTO user_billing_states (user_id, provider, stripe_customer_id)
395
+VALUES ($1, 'stripe', $2)
396
+ON CONFLICT (user_id) DO UPDATE
397
+   SET stripe_customer_id = EXCLUDED.stripe_customer_id,
398
+       provider = 'stripe',
399
+       updated_at = now()
400
+RETURNING *;
401
+
402
+-- name: ApplyUserSubscriptionSnapshot :one
403
+-- Mirrors ApplySubscriptionSnapshot for orgs minus the seat columns
404
+-- and with `user_plan` as the plan enum. The same CTE pattern keeps
405
+-- users.plan and user_billing_states.plan atomic.
406
+WITH state AS (
407
+    INSERT INTO user_billing_states (
408
+        user_id,
409
+        provider,
410
+        plan,
411
+        subscription_status,
412
+        stripe_subscription_id,
413
+        stripe_subscription_item_id,
414
+        current_period_start,
415
+        current_period_end,
416
+        cancel_at_period_end,
417
+        trial_end,
418
+        canceled_at,
419
+        last_webhook_event_id,
420
+        past_due_at,
421
+        locked_at,
422
+        lock_reason,
423
+        grace_until
424
+    )
425
+    VALUES (
426
+        sqlc.arg(user_id)::bigint,
427
+        'stripe',
428
+        sqlc.arg(plan)::user_plan,
429
+        sqlc.arg(subscription_status)::billing_subscription_status,
430
+        sqlc.narg(stripe_subscription_id)::text,
431
+        sqlc.narg(stripe_subscription_item_id)::text,
432
+        sqlc.narg(current_period_start)::timestamptz,
433
+        sqlc.narg(current_period_end)::timestamptz,
434
+        sqlc.arg(cancel_at_period_end)::boolean,
435
+        sqlc.narg(trial_end)::timestamptz,
436
+        sqlc.narg(canceled_at)::timestamptz,
437
+        sqlc.arg(last_webhook_event_id)::text,
438
+        CASE
439
+            WHEN sqlc.arg(subscription_status)::billing_subscription_status = 'past_due' THEN now()
440
+            ELSE NULL
441
+        END,
442
+        NULL,
443
+        NULL,
444
+        NULL
445
+    )
446
+    ON CONFLICT (user_id) DO UPDATE
447
+       SET plan = EXCLUDED.plan,
448
+           subscription_status = EXCLUDED.subscription_status,
449
+           stripe_subscription_id = EXCLUDED.stripe_subscription_id,
450
+           stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
451
+           current_period_start = EXCLUDED.current_period_start,
452
+           current_period_end = EXCLUDED.current_period_end,
453
+           cancel_at_period_end = EXCLUDED.cancel_at_period_end,
454
+           trial_end = EXCLUDED.trial_end,
455
+           canceled_at = EXCLUDED.canceled_at,
456
+           last_webhook_event_id = EXCLUDED.last_webhook_event_id,
457
+           past_due_at = CASE
458
+               WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(user_billing_states.past_due_at, now())
459
+               ELSE NULL
460
+           END,
461
+           locked_at = NULL,
462
+           lock_reason = NULL,
463
+           grace_until = NULL,
464
+           updated_at = now()
465
+    RETURNING *
466
+), user_update AS (
467
+    UPDATE users
468
+       SET plan = sqlc.arg(plan)::user_plan,
469
+           updated_at = now()
470
+     WHERE id = sqlc.arg(user_id)::bigint
471
+    RETURNING id
472
+)
473
+SELECT * FROM state;
474
+
475
+-- name: MarkUserPastDue :one
476
+UPDATE user_billing_states
477
+   SET subscription_status = 'past_due',
478
+       past_due_at = COALESCE(past_due_at, now()),
479
+       locked_at = now(),
480
+       lock_reason = 'past_due',
481
+       grace_until = sqlc.narg(grace_until)::timestamptz,
482
+       last_webhook_event_id = sqlc.arg(last_webhook_event_id)::text,
483
+       updated_at = now()
484
+ WHERE user_id = sqlc.arg(user_id)::bigint
485
+RETURNING *;
486
+
487
+-- name: MarkUserPaymentSucceeded :one
488
+WITH state AS (
489
+    UPDATE user_billing_states
490
+       SET plan = CASE
491
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN 'pro'
492
+               ELSE plan
493
+           END,
494
+           subscription_status = CASE
495
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN 'active'
496
+               ELSE subscription_status
497
+           END,
498
+           past_due_at = CASE
499
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL
500
+               ELSE past_due_at
501
+           END,
502
+           locked_at = NULL,
503
+           lock_reason = NULL,
504
+           grace_until = NULL,
505
+           last_webhook_event_id = sqlc.arg(last_webhook_event_id)::text,
506
+           updated_at = now()
507
+     WHERE user_id = sqlc.arg(user_id)::bigint
508
+    RETURNING *
509
+), user_update AS (
510
+    UPDATE users
511
+       SET plan = state.plan,
512
+           updated_at = now()
513
+      FROM state
514
+     WHERE users.id = state.user_id
515
+    RETURNING users.id
516
+)
517
+SELECT * FROM state;
518
+
519
+-- name: MarkUserCanceled :one
520
+WITH state AS (
521
+    UPDATE user_billing_states
522
+       SET plan = 'free',
523
+           subscription_status = 'canceled',
524
+           canceled_at = COALESCE(canceled_at, now()),
525
+           locked_at = now(),
526
+           lock_reason = 'canceled',
527
+           grace_until = NULL,
528
+           cancel_at_period_end = false,
529
+           last_webhook_event_id = sqlc.arg(last_webhook_event_id)::text,
530
+           updated_at = now()
531
+     WHERE user_id = sqlc.arg(user_id)::bigint
532
+    RETURNING *
533
+), user_update AS (
534
+    UPDATE users
535
+       SET plan = 'free',
536
+           updated_at = now()
537
+     WHERE id = sqlc.arg(user_id)::bigint
538
+    RETURNING id
539
+)
540
+SELECT * FROM state;
541
+
542
+-- name: ClearUserBillingLock :one
543
+WITH state AS (
544
+    UPDATE user_billing_states
545
+       SET plan = CASE
546
+               WHEN subscription_status = 'canceled' THEN 'free'
547
+               ELSE plan
548
+           END,
549
+           subscription_status = CASE
550
+               WHEN subscription_status = 'canceled' THEN 'none'
551
+               ELSE subscription_status
552
+           END,
553
+           locked_at = NULL,
554
+           lock_reason = NULL,
555
+           grace_until = NULL,
556
+           updated_at = now()
557
+     WHERE user_id = $1
558
+    RETURNING *
559
+), user_update AS (
560
+    UPDATE users
561
+       SET plan = state.plan,
562
+           updated_at = now()
563
+      FROM state
564
+     WHERE users.id = state.user_id
565
+    RETURNING users.id
566
+)
567
+SELECT * FROM state;
internal/billing/sqlc/billing.sql.gomodified
@@ -161,6 +161,155 @@ func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg Ap
161
 	return i, err
161
 	return i, err
162
 }
162
 }
163
 
163
 
164
+const applyUserSubscriptionSnapshot = `-- name: ApplyUserSubscriptionSnapshot :one
165
+WITH state AS (
166
+    INSERT INTO user_billing_states (
167
+        user_id,
168
+        provider,
169
+        plan,
170
+        subscription_status,
171
+        stripe_subscription_id,
172
+        stripe_subscription_item_id,
173
+        current_period_start,
174
+        current_period_end,
175
+        cancel_at_period_end,
176
+        trial_end,
177
+        canceled_at,
178
+        last_webhook_event_id,
179
+        past_due_at,
180
+        locked_at,
181
+        lock_reason,
182
+        grace_until
183
+    )
184
+    VALUES (
185
+        $1::bigint,
186
+        'stripe',
187
+        $2::user_plan,
188
+        $3::billing_subscription_status,
189
+        $4::text,
190
+        $5::text,
191
+        $6::timestamptz,
192
+        $7::timestamptz,
193
+        $8::boolean,
194
+        $9::timestamptz,
195
+        $10::timestamptz,
196
+        $11::text,
197
+        CASE
198
+            WHEN $3::billing_subscription_status = 'past_due' THEN now()
199
+            ELSE NULL
200
+        END,
201
+        NULL,
202
+        NULL,
203
+        NULL
204
+    )
205
+    ON CONFLICT (user_id) DO UPDATE
206
+       SET plan = EXCLUDED.plan,
207
+           subscription_status = EXCLUDED.subscription_status,
208
+           stripe_subscription_id = EXCLUDED.stripe_subscription_id,
209
+           stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
210
+           current_period_start = EXCLUDED.current_period_start,
211
+           current_period_end = EXCLUDED.current_period_end,
212
+           cancel_at_period_end = EXCLUDED.cancel_at_period_end,
213
+           trial_end = EXCLUDED.trial_end,
214
+           canceled_at = EXCLUDED.canceled_at,
215
+           last_webhook_event_id = EXCLUDED.last_webhook_event_id,
216
+           past_due_at = CASE
217
+               WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(user_billing_states.past_due_at, now())
218
+               ELSE NULL
219
+           END,
220
+           locked_at = NULL,
221
+           lock_reason = NULL,
222
+           grace_until = NULL,
223
+           updated_at = now()
224
+    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
225
+), user_update AS (
226
+    UPDATE users
227
+       SET plan = $2::user_plan,
228
+           updated_at = now()
229
+     WHERE id = $1::bigint
230
+    RETURNING id
231
+)
232
+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
233
+`
234
+
235
+type ApplyUserSubscriptionSnapshotParams struct {
236
+	UserID                   int64
237
+	Plan                     UserPlan
238
+	SubscriptionStatus       BillingSubscriptionStatus
239
+	StripeSubscriptionID     pgtype.Text
240
+	StripeSubscriptionItemID pgtype.Text
241
+	CurrentPeriodStart       pgtype.Timestamptz
242
+	CurrentPeriodEnd         pgtype.Timestamptz
243
+	CancelAtPeriodEnd        bool
244
+	TrialEnd                 pgtype.Timestamptz
245
+	CanceledAt               pgtype.Timestamptz
246
+	LastWebhookEventID       string
247
+}
248
+
249
+type ApplyUserSubscriptionSnapshotRow struct {
250
+	UserID                   int64
251
+	Provider                 BillingProvider
252
+	StripeCustomerID         pgtype.Text
253
+	StripeSubscriptionID     pgtype.Text
254
+	StripeSubscriptionItemID pgtype.Text
255
+	Plan                     UserPlan
256
+	SubscriptionStatus       BillingSubscriptionStatus
257
+	CurrentPeriodStart       pgtype.Timestamptz
258
+	CurrentPeriodEnd         pgtype.Timestamptz
259
+	CancelAtPeriodEnd        bool
260
+	TrialEnd                 pgtype.Timestamptz
261
+	PastDueAt                pgtype.Timestamptz
262
+	CanceledAt               pgtype.Timestamptz
263
+	LockedAt                 pgtype.Timestamptz
264
+	LockReason               NullBillingLockReason
265
+	GraceUntil               pgtype.Timestamptz
266
+	LastWebhookEventID       string
267
+	CreatedAt                pgtype.Timestamptz
268
+	UpdatedAt                pgtype.Timestamptz
269
+}
270
+
271
+// Mirrors ApplySubscriptionSnapshot for orgs minus the seat columns
272
+// and with `user_plan` as the plan enum. The same CTE pattern keeps
273
+// users.plan and user_billing_states.plan atomic.
274
+func (q *Queries) ApplyUserSubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplyUserSubscriptionSnapshotParams) (ApplyUserSubscriptionSnapshotRow, error) {
275
+	row := db.QueryRow(ctx, applyUserSubscriptionSnapshot,
276
+		arg.UserID,
277
+		arg.Plan,
278
+		arg.SubscriptionStatus,
279
+		arg.StripeSubscriptionID,
280
+		arg.StripeSubscriptionItemID,
281
+		arg.CurrentPeriodStart,
282
+		arg.CurrentPeriodEnd,
283
+		arg.CancelAtPeriodEnd,
284
+		arg.TrialEnd,
285
+		arg.CanceledAt,
286
+		arg.LastWebhookEventID,
287
+	)
288
+	var i ApplyUserSubscriptionSnapshotRow
289
+	err := row.Scan(
290
+		&i.UserID,
291
+		&i.Provider,
292
+		&i.StripeCustomerID,
293
+		&i.StripeSubscriptionID,
294
+		&i.StripeSubscriptionItemID,
295
+		&i.Plan,
296
+		&i.SubscriptionStatus,
297
+		&i.CurrentPeriodStart,
298
+		&i.CurrentPeriodEnd,
299
+		&i.CancelAtPeriodEnd,
300
+		&i.TrialEnd,
301
+		&i.PastDueAt,
302
+		&i.CanceledAt,
303
+		&i.LockedAt,
304
+		&i.LockReason,
305
+		&i.GraceUntil,
306
+		&i.LastWebhookEventID,
307
+		&i.CreatedAt,
308
+		&i.UpdatedAt,
309
+	)
310
+	return i, err
311
+}
312
+
164
 const clearBillingLock = `-- name: ClearBillingLock :one
313
 const clearBillingLock = `-- name: ClearBillingLock :one
165
 WITH state AS (
314
 WITH state AS (
166
     UPDATE org_billing_states
315
     UPDATE org_billing_states
@@ -242,6 +391,83 @@ func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (C
242
 	return i, err
391
 	return i, err
243
 }
392
 }
244
 
393
 
394
+const clearUserBillingLock = `-- name: ClearUserBillingLock :one
395
+WITH state AS (
396
+    UPDATE user_billing_states
397
+       SET plan = CASE
398
+               WHEN subscription_status = 'canceled' THEN 'free'
399
+               ELSE plan
400
+           END,
401
+           subscription_status = CASE
402
+               WHEN subscription_status = 'canceled' THEN 'none'
403
+               ELSE subscription_status
404
+           END,
405
+           locked_at = NULL,
406
+           lock_reason = NULL,
407
+           grace_until = NULL,
408
+           updated_at = now()
409
+     WHERE user_id = $1
410
+    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
411
+), user_update AS (
412
+    UPDATE users
413
+       SET plan = state.plan,
414
+           updated_at = now()
415
+      FROM state
416
+     WHERE users.id = state.user_id
417
+    RETURNING users.id
418
+)
419
+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
420
+`
421
+
422
+type ClearUserBillingLockRow struct {
423
+	UserID                   int64
424
+	Provider                 BillingProvider
425
+	StripeCustomerID         pgtype.Text
426
+	StripeSubscriptionID     pgtype.Text
427
+	StripeSubscriptionItemID pgtype.Text
428
+	Plan                     UserPlan
429
+	SubscriptionStatus       BillingSubscriptionStatus
430
+	CurrentPeriodStart       pgtype.Timestamptz
431
+	CurrentPeriodEnd         pgtype.Timestamptz
432
+	CancelAtPeriodEnd        bool
433
+	TrialEnd                 pgtype.Timestamptz
434
+	PastDueAt                pgtype.Timestamptz
435
+	CanceledAt               pgtype.Timestamptz
436
+	LockedAt                 pgtype.Timestamptz
437
+	LockReason               NullBillingLockReason
438
+	GraceUntil               pgtype.Timestamptz
439
+	LastWebhookEventID       string
440
+	CreatedAt                pgtype.Timestamptz
441
+	UpdatedAt                pgtype.Timestamptz
442
+}
443
+
444
+func (q *Queries) ClearUserBillingLock(ctx context.Context, db DBTX, userID int64) (ClearUserBillingLockRow, error) {
445
+	row := db.QueryRow(ctx, clearUserBillingLock, userID)
446
+	var i ClearUserBillingLockRow
447
+	err := row.Scan(
448
+		&i.UserID,
449
+		&i.Provider,
450
+		&i.StripeCustomerID,
451
+		&i.StripeSubscriptionID,
452
+		&i.StripeSubscriptionItemID,
453
+		&i.Plan,
454
+		&i.SubscriptionStatus,
455
+		&i.CurrentPeriodStart,
456
+		&i.CurrentPeriodEnd,
457
+		&i.CancelAtPeriodEnd,
458
+		&i.TrialEnd,
459
+		&i.PastDueAt,
460
+		&i.CanceledAt,
461
+		&i.LockedAt,
462
+		&i.LockReason,
463
+		&i.GraceUntil,
464
+		&i.LastWebhookEventID,
465
+		&i.CreatedAt,
466
+		&i.UpdatedAt,
467
+	)
468
+	return i, err
469
+}
470
+
245
 const countBillableOrgMembers = `-- name: CountBillableOrgMembers :one
471
 const countBillableOrgMembers = `-- name: CountBillableOrgMembers :one
246
 SELECT count(*)::integer
472
 SELECT count(*)::integer
247
 FROM org_members
473
 FROM org_members
@@ -363,7 +589,7 @@ VALUES (
363
     $4::jsonb
589
     $4::jsonb
364
 )
590
 )
365
 ON CONFLICT (provider, provider_event_id) DO NOTHING
591
 ON CONFLICT (provider, provider_event_id) DO NOTHING
366
-RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
592
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts, subject_kind, subject_id
367
 `
593
 `
368
 
594
 
369
 type CreateWebhookEventReceiptParams struct {
595
 type CreateWebhookEventReceiptParams struct {
@@ -393,6 +619,8 @@ func (q *Queries) CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg Cr
393
 		&i.ProcessedAt,
619
 		&i.ProcessedAt,
394
 		&i.ProcessError,
620
 		&i.ProcessError,
395
 		&i.ProcessingAttempts,
621
 		&i.ProcessingAttempts,
622
+		&i.SubjectKind,
623
+		&i.SubjectID,
396
 	)
624
 	)
397
 	return i, err
625
 	return i, err
398
 }
626
 }
@@ -504,8 +732,107 @@ func (q *Queries) GetOrgBillingStateByStripeSubscription(ctx context.Context, db
504
 	return i, err
732
 	return i, err
505
 }
733
 }
506
 
734
 
735
+const getUserBillingState = `-- name: GetUserBillingState :one
736
+
737
+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
738
+`
739
+
740
+// ─── user_billing_states (PRO03) ──────────────────────────────────
741
+func (q *Queries) GetUserBillingState(ctx context.Context, db DBTX, userID int64) (UserBillingState, error) {
742
+	row := db.QueryRow(ctx, getUserBillingState, userID)
743
+	var i UserBillingState
744
+	err := row.Scan(
745
+		&i.UserID,
746
+		&i.Provider,
747
+		&i.StripeCustomerID,
748
+		&i.StripeSubscriptionID,
749
+		&i.StripeSubscriptionItemID,
750
+		&i.Plan,
751
+		&i.SubscriptionStatus,
752
+		&i.CurrentPeriodStart,
753
+		&i.CurrentPeriodEnd,
754
+		&i.CancelAtPeriodEnd,
755
+		&i.TrialEnd,
756
+		&i.PastDueAt,
757
+		&i.CanceledAt,
758
+		&i.LockedAt,
759
+		&i.LockReason,
760
+		&i.GraceUntil,
761
+		&i.LastWebhookEventID,
762
+		&i.CreatedAt,
763
+		&i.UpdatedAt,
764
+	)
765
+	return i, err
766
+}
767
+
768
+const getUserBillingStateByStripeCustomer = `-- name: GetUserBillingStateByStripeCustomer :one
769
+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
770
+WHERE provider = 'stripe'
771
+  AND stripe_customer_id = $1
772
+`
773
+
774
+func (q *Queries) GetUserBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (UserBillingState, error) {
775
+	row := db.QueryRow(ctx, getUserBillingStateByStripeCustomer, stripeCustomerID)
776
+	var i UserBillingState
777
+	err := row.Scan(
778
+		&i.UserID,
779
+		&i.Provider,
780
+		&i.StripeCustomerID,
781
+		&i.StripeSubscriptionID,
782
+		&i.StripeSubscriptionItemID,
783
+		&i.Plan,
784
+		&i.SubscriptionStatus,
785
+		&i.CurrentPeriodStart,
786
+		&i.CurrentPeriodEnd,
787
+		&i.CancelAtPeriodEnd,
788
+		&i.TrialEnd,
789
+		&i.PastDueAt,
790
+		&i.CanceledAt,
791
+		&i.LockedAt,
792
+		&i.LockReason,
793
+		&i.GraceUntil,
794
+		&i.LastWebhookEventID,
795
+		&i.CreatedAt,
796
+		&i.UpdatedAt,
797
+	)
798
+	return i, err
799
+}
800
+
801
+const getUserBillingStateByStripeSubscription = `-- name: GetUserBillingStateByStripeSubscription :one
802
+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
803
+WHERE provider = 'stripe'
804
+  AND stripe_subscription_id = $1
805
+`
806
+
807
+func (q *Queries) GetUserBillingStateByStripeSubscription(ctx context.Context, db DBTX, stripeSubscriptionID pgtype.Text) (UserBillingState, error) {
808
+	row := db.QueryRow(ctx, getUserBillingStateByStripeSubscription, stripeSubscriptionID)
809
+	var i UserBillingState
810
+	err := row.Scan(
811
+		&i.UserID,
812
+		&i.Provider,
813
+		&i.StripeCustomerID,
814
+		&i.StripeSubscriptionID,
815
+		&i.StripeSubscriptionItemID,
816
+		&i.Plan,
817
+		&i.SubscriptionStatus,
818
+		&i.CurrentPeriodStart,
819
+		&i.CurrentPeriodEnd,
820
+		&i.CancelAtPeriodEnd,
821
+		&i.TrialEnd,
822
+		&i.PastDueAt,
823
+		&i.CanceledAt,
824
+		&i.LockedAt,
825
+		&i.LockReason,
826
+		&i.GraceUntil,
827
+		&i.LastWebhookEventID,
828
+		&i.CreatedAt,
829
+		&i.UpdatedAt,
830
+	)
831
+	return i, err
832
+}
833
+
507
 const getWebhookEventReceipt = `-- name: GetWebhookEventReceipt :one
834
 const getWebhookEventReceipt = `-- name: GetWebhookEventReceipt :one
508
-SELECT id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts FROM billing_webhook_events
835
+SELECT id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts, subject_kind, subject_id FROM billing_webhook_events
509
 WHERE provider = 'stripe'
836
 WHERE provider = 'stripe'
510
   AND provider_event_id = $1
837
   AND provider_event_id = $1
511
 `
838
 `
@@ -524,24 +851,92 @@ func (q *Queries) GetWebhookEventReceipt(ctx context.Context, db DBTX, providerE
524
 		&i.ProcessedAt,
851
 		&i.ProcessedAt,
525
 		&i.ProcessError,
852
 		&i.ProcessError,
526
 		&i.ProcessingAttempts,
853
 		&i.ProcessingAttempts,
854
+		&i.SubjectKind,
855
+		&i.SubjectID,
527
 	)
856
 	)
528
 	return i, err
857
 	return i, err
529
 }
858
 }
530
 
859
 
531
 const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many
860
 const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many
532
-SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at FROM billing_invoices
861
+SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id FROM billing_invoices
533
-WHERE org_id = $1
862
+WHERE subject_kind = 'org' AND subject_id = $1
534
 ORDER BY created_at DESC, id DESC
863
 ORDER BY created_at DESC, id DESC
535
 LIMIT $2
864
 LIMIT $2
536
 `
865
 `
537
 
866
 
538
 type ListInvoicesForOrgParams struct {
867
 type ListInvoicesForOrgParams struct {
539
-	OrgID int64
868
+	SubjectID int64
540
-	Limit int32
869
+	Limit     int32
541
 }
870
 }
542
 
871
 
872
+// PRO03: filters on the polymorphic subject columns so the index
873
+// billing_invoices_subject_created_idx services this query. The
874
+// legacy `org_id` column is kept populated by UpsertInvoice for the
875
+// transitional window; this query no longer reads it.
543
 func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error) {
876
 func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error) {
544
-	rows, err := db.Query(ctx, listInvoicesForOrg, arg.OrgID, arg.Limit)
877
+	rows, err := db.Query(ctx, listInvoicesForOrg, arg.SubjectID, arg.Limit)
878
+	if err != nil {
879
+		return nil, err
880
+	}
881
+	defer rows.Close()
882
+	items := []BillingInvoice{}
883
+	for rows.Next() {
884
+		var i BillingInvoice
885
+		if err := rows.Scan(
886
+			&i.ID,
887
+			&i.OrgID,
888
+			&i.Provider,
889
+			&i.StripeInvoiceID,
890
+			&i.StripeCustomerID,
891
+			&i.StripeSubscriptionID,
892
+			&i.Status,
893
+			&i.Number,
894
+			&i.Currency,
895
+			&i.AmountDueCents,
896
+			&i.AmountPaidCents,
897
+			&i.AmountRemainingCents,
898
+			&i.HostedInvoiceUrl,
899
+			&i.InvoicePdfUrl,
900
+			&i.PeriodStart,
901
+			&i.PeriodEnd,
902
+			&i.DueAt,
903
+			&i.PaidAt,
904
+			&i.VoidedAt,
905
+			&i.CreatedAt,
906
+			&i.UpdatedAt,
907
+			&i.SubjectKind,
908
+			&i.SubjectID,
909
+		); err != nil {
910
+			return nil, err
911
+		}
912
+		items = append(items, i)
913
+	}
914
+	if err := rows.Err(); err != nil {
915
+		return nil, err
916
+	}
917
+	return items, nil
918
+}
919
+
920
+const listInvoicesForSubject = `-- name: ListInvoicesForSubject :many
921
+SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id FROM billing_invoices
922
+WHERE subject_kind = $1::billing_subject_kind
923
+  AND subject_id = $2::bigint
924
+ORDER BY created_at DESC, id DESC
925
+LIMIT $3::integer
926
+`
927
+
928
+type ListInvoicesForSubjectParams struct {
929
+	SubjectKind BillingSubjectKind
930
+	SubjectID   int64
931
+	Lim         int32
932
+}
933
+
934
+// Polymorphic invoice listing for PRO04+ callers. The org-flavored
935
+// ListInvoicesForOrg above is the same query with subject_kind
936
+// hard-coded; this surface lets a user-side caller pass kind='user'
937
+// without forking the helper.
938
+func (q *Queries) ListInvoicesForSubject(ctx context.Context, db DBTX, arg ListInvoicesForSubjectParams) ([]BillingInvoice, error) {
939
+	rows, err := db.Query(ctx, listInvoicesForSubject, arg.SubjectKind, arg.SubjectID, arg.Lim)
545
 	if err != nil {
940
 	if err != nil {
546
 		return nil, err
941
 		return nil, err
547
 	}
942
 	}
@@ -571,6 +966,8 @@ func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoi
571
 			&i.VoidedAt,
966
 			&i.VoidedAt,
572
 			&i.CreatedAt,
967
 			&i.CreatedAt,
573
 			&i.UpdatedAt,
968
 			&i.UpdatedAt,
969
+			&i.SubjectKind,
970
+			&i.SubjectID,
574
 		); err != nil {
971
 		); err != nil {
575
 			return nil, err
972
 			return nil, err
576
 		}
973
 		}
@@ -844,13 +1241,224 @@ func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPay
844
 	return i, err
1241
 	return i, err
845
 }
1242
 }
846
 
1243
 
1244
+const markUserCanceled = `-- name: MarkUserCanceled :one
1245
+WITH state AS (
1246
+    UPDATE user_billing_states
1247
+       SET plan = 'free',
1248
+           subscription_status = 'canceled',
1249
+           canceled_at = COALESCE(canceled_at, now()),
1250
+           locked_at = now(),
1251
+           lock_reason = 'canceled',
1252
+           grace_until = NULL,
1253
+           cancel_at_period_end = false,
1254
+           last_webhook_event_id = $1::text,
1255
+           updated_at = now()
1256
+     WHERE user_id = $2::bigint
1257
+    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
1258
+), user_update AS (
1259
+    UPDATE users
1260
+       SET plan = 'free',
1261
+           updated_at = now()
1262
+     WHERE id = $2::bigint
1263
+    RETURNING id
1264
+)
1265
+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
1266
+`
1267
+
1268
+type MarkUserCanceledParams struct {
1269
+	LastWebhookEventID string
1270
+	UserID             int64
1271
+}
1272
+
1273
+type MarkUserCanceledRow struct {
1274
+	UserID                   int64
1275
+	Provider                 BillingProvider
1276
+	StripeCustomerID         pgtype.Text
1277
+	StripeSubscriptionID     pgtype.Text
1278
+	StripeSubscriptionItemID pgtype.Text
1279
+	Plan                     UserPlan
1280
+	SubscriptionStatus       BillingSubscriptionStatus
1281
+	CurrentPeriodStart       pgtype.Timestamptz
1282
+	CurrentPeriodEnd         pgtype.Timestamptz
1283
+	CancelAtPeriodEnd        bool
1284
+	TrialEnd                 pgtype.Timestamptz
1285
+	PastDueAt                pgtype.Timestamptz
1286
+	CanceledAt               pgtype.Timestamptz
1287
+	LockedAt                 pgtype.Timestamptz
1288
+	LockReason               NullBillingLockReason
1289
+	GraceUntil               pgtype.Timestamptz
1290
+	LastWebhookEventID       string
1291
+	CreatedAt                pgtype.Timestamptz
1292
+	UpdatedAt                pgtype.Timestamptz
1293
+}
1294
+
1295
+func (q *Queries) MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCanceledParams) (MarkUserCanceledRow, error) {
1296
+	row := db.QueryRow(ctx, markUserCanceled, arg.LastWebhookEventID, arg.UserID)
1297
+	var i MarkUserCanceledRow
1298
+	err := row.Scan(
1299
+		&i.UserID,
1300
+		&i.Provider,
1301
+		&i.StripeCustomerID,
1302
+		&i.StripeSubscriptionID,
1303
+		&i.StripeSubscriptionItemID,
1304
+		&i.Plan,
1305
+		&i.SubscriptionStatus,
1306
+		&i.CurrentPeriodStart,
1307
+		&i.CurrentPeriodEnd,
1308
+		&i.CancelAtPeriodEnd,
1309
+		&i.TrialEnd,
1310
+		&i.PastDueAt,
1311
+		&i.CanceledAt,
1312
+		&i.LockedAt,
1313
+		&i.LockReason,
1314
+		&i.GraceUntil,
1315
+		&i.LastWebhookEventID,
1316
+		&i.CreatedAt,
1317
+		&i.UpdatedAt,
1318
+	)
1319
+	return i, err
1320
+}
1321
+
1322
+const markUserPastDue = `-- name: MarkUserPastDue :one
1323
+UPDATE user_billing_states
1324
+   SET subscription_status = 'past_due',
1325
+       past_due_at = COALESCE(past_due_at, now()),
1326
+       locked_at = now(),
1327
+       lock_reason = 'past_due',
1328
+       grace_until = $1::timestamptz,
1329
+       last_webhook_event_id = $2::text,
1330
+       updated_at = now()
1331
+ WHERE user_id = $3::bigint
1332
+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
1333
+`
1334
+
1335
+type MarkUserPastDueParams struct {
1336
+	GraceUntil         pgtype.Timestamptz
1337
+	LastWebhookEventID string
1338
+	UserID             int64
1339
+}
1340
+
1341
+func (q *Queries) MarkUserPastDue(ctx context.Context, db DBTX, arg MarkUserPastDueParams) (UserBillingState, error) {
1342
+	row := db.QueryRow(ctx, markUserPastDue, arg.GraceUntil, arg.LastWebhookEventID, arg.UserID)
1343
+	var i UserBillingState
1344
+	err := row.Scan(
1345
+		&i.UserID,
1346
+		&i.Provider,
1347
+		&i.StripeCustomerID,
1348
+		&i.StripeSubscriptionID,
1349
+		&i.StripeSubscriptionItemID,
1350
+		&i.Plan,
1351
+		&i.SubscriptionStatus,
1352
+		&i.CurrentPeriodStart,
1353
+		&i.CurrentPeriodEnd,
1354
+		&i.CancelAtPeriodEnd,
1355
+		&i.TrialEnd,
1356
+		&i.PastDueAt,
1357
+		&i.CanceledAt,
1358
+		&i.LockedAt,
1359
+		&i.LockReason,
1360
+		&i.GraceUntil,
1361
+		&i.LastWebhookEventID,
1362
+		&i.CreatedAt,
1363
+		&i.UpdatedAt,
1364
+	)
1365
+	return i, err
1366
+}
1367
+
1368
+const markUserPaymentSucceeded = `-- name: MarkUserPaymentSucceeded :one
1369
+WITH state AS (
1370
+    UPDATE user_billing_states
1371
+       SET plan = CASE
1372
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN 'pro'
1373
+               ELSE plan
1374
+           END,
1375
+           subscription_status = CASE
1376
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN 'active'
1377
+               ELSE subscription_status
1378
+           END,
1379
+           past_due_at = CASE
1380
+               WHEN subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL
1381
+               ELSE past_due_at
1382
+           END,
1383
+           locked_at = NULL,
1384
+           lock_reason = NULL,
1385
+           grace_until = NULL,
1386
+           last_webhook_event_id = $1::text,
1387
+           updated_at = now()
1388
+     WHERE user_id = $2::bigint
1389
+    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
1390
+), user_update AS (
1391
+    UPDATE users
1392
+       SET plan = state.plan,
1393
+           updated_at = now()
1394
+      FROM state
1395
+     WHERE users.id = state.user_id
1396
+    RETURNING users.id
1397
+)
1398
+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
1399
+`
1400
+
1401
+type MarkUserPaymentSucceededParams struct {
1402
+	LastWebhookEventID string
1403
+	UserID             int64
1404
+}
1405
+
1406
+type MarkUserPaymentSucceededRow struct {
1407
+	UserID                   int64
1408
+	Provider                 BillingProvider
1409
+	StripeCustomerID         pgtype.Text
1410
+	StripeSubscriptionID     pgtype.Text
1411
+	StripeSubscriptionItemID pgtype.Text
1412
+	Plan                     UserPlan
1413
+	SubscriptionStatus       BillingSubscriptionStatus
1414
+	CurrentPeriodStart       pgtype.Timestamptz
1415
+	CurrentPeriodEnd         pgtype.Timestamptz
1416
+	CancelAtPeriodEnd        bool
1417
+	TrialEnd                 pgtype.Timestamptz
1418
+	PastDueAt                pgtype.Timestamptz
1419
+	CanceledAt               pgtype.Timestamptz
1420
+	LockedAt                 pgtype.Timestamptz
1421
+	LockReason               NullBillingLockReason
1422
+	GraceUntil               pgtype.Timestamptz
1423
+	LastWebhookEventID       string
1424
+	CreatedAt                pgtype.Timestamptz
1425
+	UpdatedAt                pgtype.Timestamptz
1426
+}
1427
+
1428
+func (q *Queries) MarkUserPaymentSucceeded(ctx context.Context, db DBTX, arg MarkUserPaymentSucceededParams) (MarkUserPaymentSucceededRow, error) {
1429
+	row := db.QueryRow(ctx, markUserPaymentSucceeded, arg.LastWebhookEventID, arg.UserID)
1430
+	var i MarkUserPaymentSucceededRow
1431
+	err := row.Scan(
1432
+		&i.UserID,
1433
+		&i.Provider,
1434
+		&i.StripeCustomerID,
1435
+		&i.StripeSubscriptionID,
1436
+		&i.StripeSubscriptionItemID,
1437
+		&i.Plan,
1438
+		&i.SubscriptionStatus,
1439
+		&i.CurrentPeriodStart,
1440
+		&i.CurrentPeriodEnd,
1441
+		&i.CancelAtPeriodEnd,
1442
+		&i.TrialEnd,
1443
+		&i.PastDueAt,
1444
+		&i.CanceledAt,
1445
+		&i.LockedAt,
1446
+		&i.LockReason,
1447
+		&i.GraceUntil,
1448
+		&i.LastWebhookEventID,
1449
+		&i.CreatedAt,
1450
+		&i.UpdatedAt,
1451
+	)
1452
+	return i, err
1453
+}
1454
+
847
 const markWebhookEventFailed = `-- name: MarkWebhookEventFailed :one
1455
 const markWebhookEventFailed = `-- name: MarkWebhookEventFailed :one
848
 UPDATE billing_webhook_events
1456
 UPDATE billing_webhook_events
849
    SET process_error = $2,
1457
    SET process_error = $2,
850
        processing_attempts = processing_attempts + 1
1458
        processing_attempts = processing_attempts + 1
851
  WHERE provider = 'stripe'
1459
  WHERE provider = 'stripe'
852
    AND provider_event_id = $1
1460
    AND provider_event_id = $1
853
-RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
1461
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts, subject_kind, subject_id
854
 `
1462
 `
855
 
1463
 
856
 type MarkWebhookEventFailedParams struct {
1464
 type MarkWebhookEventFailedParams struct {
@@ -872,6 +1480,8 @@ func (q *Queries) MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkW
872
 		&i.ProcessedAt,
1480
 		&i.ProcessedAt,
873
 		&i.ProcessError,
1481
 		&i.ProcessError,
874
 		&i.ProcessingAttempts,
1482
 		&i.ProcessingAttempts,
1483
+		&i.SubjectKind,
1484
+		&i.SubjectID,
875
 	)
1485
 	)
876
 	return i, err
1486
 	return i, err
877
 }
1487
 }
@@ -883,7 +1493,7 @@ UPDATE billing_webhook_events
883
        processing_attempts = processing_attempts + 1
1493
        processing_attempts = processing_attempts + 1
884
  WHERE provider = 'stripe'
1494
  WHERE provider = 'stripe'
885
    AND provider_event_id = $1
1495
    AND provider_event_id = $1
886
-RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts
1496
+RETURNING id, provider, provider_event_id, event_type, api_version, payload, received_at, processed_at, process_error, processing_attempts, subject_kind, subject_id
887
 `
1497
 `
888
 
1498
 
889
 func (q *Queries) MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error) {
1499
 func (q *Queries) MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error) {
@@ -900,6 +1510,8 @@ func (q *Queries) MarkWebhookEventProcessed(ctx context.Context, db DBTX, provid
900
 		&i.ProcessedAt,
1510
 		&i.ProcessedAt,
901
 		&i.ProcessError,
1511
 		&i.ProcessError,
902
 		&i.ProcessingAttempts,
1512
 		&i.ProcessingAttempts,
1513
+		&i.SubjectKind,
1514
+		&i.SubjectID,
903
 	)
1515
 	)
904
 	return i, err
1516
 	return i, err
905
 }
1517
 }
@@ -948,10 +1560,54 @@ func (q *Queries) SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeC
948
 	return i, err
1560
 	return i, err
949
 }
1561
 }
950
 
1562
 
1563
+const setUserStripeCustomer = `-- name: SetUserStripeCustomer :one
1564
+INSERT INTO user_billing_states (user_id, provider, stripe_customer_id)
1565
+VALUES ($1, 'stripe', $2)
1566
+ON CONFLICT (user_id) DO UPDATE
1567
+   SET stripe_customer_id = EXCLUDED.stripe_customer_id,
1568
+       provider = 'stripe',
1569
+       updated_at = now()
1570
+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
1571
+`
1572
+
1573
+type SetUserStripeCustomerParams struct {
1574
+	UserID           int64
1575
+	StripeCustomerID pgtype.Text
1576
+}
1577
+
1578
+func (q *Queries) SetUserStripeCustomer(ctx context.Context, db DBTX, arg SetUserStripeCustomerParams) (UserBillingState, error) {
1579
+	row := db.QueryRow(ctx, setUserStripeCustomer, arg.UserID, arg.StripeCustomerID)
1580
+	var i UserBillingState
1581
+	err := row.Scan(
1582
+		&i.UserID,
1583
+		&i.Provider,
1584
+		&i.StripeCustomerID,
1585
+		&i.StripeSubscriptionID,
1586
+		&i.StripeSubscriptionItemID,
1587
+		&i.Plan,
1588
+		&i.SubscriptionStatus,
1589
+		&i.CurrentPeriodStart,
1590
+		&i.CurrentPeriodEnd,
1591
+		&i.CancelAtPeriodEnd,
1592
+		&i.TrialEnd,
1593
+		&i.PastDueAt,
1594
+		&i.CanceledAt,
1595
+		&i.LockedAt,
1596
+		&i.LockReason,
1597
+		&i.GraceUntil,
1598
+		&i.LastWebhookEventID,
1599
+		&i.CreatedAt,
1600
+		&i.UpdatedAt,
1601
+	)
1602
+	return i, err
1603
+}
1604
+
951
 const upsertInvoice = `-- name: UpsertInvoice :one
1605
 const upsertInvoice = `-- name: UpsertInvoice :one
952
 
1606
 
953
 INSERT INTO billing_invoices (
1607
 INSERT INTO billing_invoices (
954
     org_id,
1608
     org_id,
1609
+    subject_kind,
1610
+    subject_id,
955
     provider,
1611
     provider,
956
     stripe_invoice_id,
1612
     stripe_invoice_id,
957
     stripe_customer_id,
1613
     stripe_customer_id,
@@ -971,6 +1627,8 @@ INSERT INTO billing_invoices (
971
     voided_at
1627
     voided_at
972
 )
1628
 )
973
 VALUES (
1629
 VALUES (
1630
+    $1::bigint,
1631
+    'org'::billing_subject_kind,
974
     $1::bigint,
1632
     $1::bigint,
975
     'stripe',
1633
     'stripe',
976
     $2::text,
1634
     $2::text,
@@ -1008,7 +1666,7 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
1008
        paid_at = EXCLUDED.paid_at,
1666
        paid_at = EXCLUDED.paid_at,
1009
        voided_at = EXCLUDED.voided_at,
1667
        voided_at = EXCLUDED.voided_at,
1010
        updated_at = now()
1668
        updated_at = now()
1011
-RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at
1669
+RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id
1012
 `
1670
 `
1013
 
1671
 
1014
 type UpsertInvoiceParams struct {
1672
 type UpsertInvoiceParams struct {
@@ -1032,6 +1690,11 @@ type UpsertInvoiceParams struct {
1032
 }
1690
 }
1033
 
1691
 
1034
 // ─── billing_invoices ──────────────────────────────────────────────
1692
 // ─── billing_invoices ──────────────────────────────────────────────
1693
+// PRO03: writes both legacy `org_id` and polymorphic
1694
+// `(subject_kind, subject_id)`. Callers continue to bind org_id only;
1695
+// the subject columns are derived. After PRO04 migrates all callers
1696
+// to the polymorphic shape, a follow-up migration drops `org_id` and
1697
+// this query loses the legacy column from its INSERT list.
1035
 func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error) {
1698
 func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error) {
1036
 	row := db.QueryRow(ctx, upsertInvoice,
1699
 	row := db.QueryRow(ctx, upsertInvoice,
1037
 		arg.OrgID,
1700
 		arg.OrgID,
@@ -1075,6 +1738,8 @@ func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceP
1075
 		&i.VoidedAt,
1738
 		&i.VoidedAt,
1076
 		&i.CreatedAt,
1739
 		&i.CreatedAt,
1077
 		&i.UpdatedAt,
1740
 		&i.UpdatedAt,
1741
+		&i.SubjectKind,
1742
+		&i.SubjectID,
1078
 	)
1743
 	)
1079
 	return i, err
1744
 	return i, err
1080
 }
1745
 }
internal/billing/sqlc/querier.gomodified
@@ -12,7 +12,12 @@ import (
12
 
12
 
13
 type Querier interface {
13
 type Querier interface {
14
 	ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error)
14
 	ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplySubscriptionSnapshotParams) (ApplySubscriptionSnapshotRow, error)
15
+	// Mirrors ApplySubscriptionSnapshot for orgs minus the seat columns
16
+	// and with `user_plan` as the plan enum. The same CTE pattern keeps
17
+	// users.plan and user_billing_states.plan atomic.
18
+	ApplyUserSubscriptionSnapshot(ctx context.Context, db DBTX, arg ApplyUserSubscriptionSnapshotParams) (ApplyUserSubscriptionSnapshotRow, error)
15
 	ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error)
19
 	ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error)
20
+	ClearUserBillingLock(ctx context.Context, db DBTX, userID int64) (ClearUserBillingLockRow, error)
16
 	CountBillableOrgMembers(ctx context.Context, db DBTX, orgID int64) (int32, error)
21
 	CountBillableOrgMembers(ctx context.Context, db DBTX, orgID int64) (int32, error)
17
 	CountPendingOrgInvitations(ctx context.Context, db DBTX, orgID int64) (int32, error)
22
 	CountPendingOrgInvitations(ctx context.Context, db DBTX, orgID int64) (int32, error)
18
 	// ─── billing_seat_snapshots ────────────────────────────────────────
23
 	// ─── billing_seat_snapshots ────────────────────────────────────────
@@ -24,16 +29,38 @@ type Querier interface {
24
 	GetOrgBillingState(ctx context.Context, db DBTX, orgID int64) (OrgBillingState, error)
29
 	GetOrgBillingState(ctx context.Context, db DBTX, orgID int64) (OrgBillingState, error)
25
 	GetOrgBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (OrgBillingState, error)
30
 	GetOrgBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (OrgBillingState, error)
26
 	GetOrgBillingStateByStripeSubscription(ctx context.Context, db DBTX, stripeSubscriptionID pgtype.Text) (OrgBillingState, error)
31
 	GetOrgBillingStateByStripeSubscription(ctx context.Context, db DBTX, stripeSubscriptionID pgtype.Text) (OrgBillingState, error)
32
+	// ─── user_billing_states (PRO03) ──────────────────────────────────
33
+	GetUserBillingState(ctx context.Context, db DBTX, userID int64) (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)
27
 	GetWebhookEventReceipt(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
36
 	GetWebhookEventReceipt(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
37
+	// PRO03: filters on the polymorphic subject columns so the index
38
+	// billing_invoices_subject_created_idx services this query. The
39
+	// legacy `org_id` column is kept populated by UpsertInvoice for the
40
+	// transitional window; this query no longer reads it.
28
 	ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error)
41
 	ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoicesForOrgParams) ([]BillingInvoice, error)
42
+	// Polymorphic invoice listing for PRO04+ callers. The org-flavored
43
+	// ListInvoicesForOrg above is the same query with subject_kind
44
+	// hard-coded; this surface lets a user-side caller pass kind='user'
45
+	// without forking the helper.
46
+	ListInvoicesForSubject(ctx context.Context, db DBTX, arg ListInvoicesForSubjectParams) ([]BillingInvoice, error)
29
 	ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error)
47
 	ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error)
30
 	MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error)
48
 	MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error)
31
 	MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error)
49
 	MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error)
32
 	MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPaymentSucceededParams) (MarkPaymentSucceededRow, error)
50
 	MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPaymentSucceededParams) (MarkPaymentSucceededRow, error)
51
+	MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCanceledParams) (MarkUserCanceledRow, error)
52
+	MarkUserPastDue(ctx context.Context, db DBTX, arg MarkUserPastDueParams) (UserBillingState, error)
53
+	MarkUserPaymentSucceeded(ctx context.Context, db DBTX, arg MarkUserPaymentSucceededParams) (MarkUserPaymentSucceededRow, error)
33
 	MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkWebhookEventFailedParams) (BillingWebhookEvent, error)
54
 	MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkWebhookEventFailedParams) (BillingWebhookEvent, error)
34
 	MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
55
 	MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
35
 	SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeCustomerParams) (OrgBillingState, error)
56
 	SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeCustomerParams) (OrgBillingState, error)
57
+	SetUserStripeCustomer(ctx context.Context, db DBTX, arg SetUserStripeCustomerParams) (UserBillingState, error)
36
 	// ─── billing_invoices ──────────────────────────────────────────────
58
 	// ─── billing_invoices ──────────────────────────────────────────────
59
+	// PRO03: writes both legacy `org_id` and polymorphic
60
+	// `(subject_kind, subject_id)`. Callers continue to bind org_id only;
61
+	// the subject columns are derived. After PRO04 migrates all callers
62
+	// to the polymorphic shape, a follow-up migration drops `org_id` and
63
+	// this query loses the legacy column from its INSERT list.
37
 	UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error)
64
 	UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error)
38
 }
65
 }
39
 
66