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
328328
 		limit = 10
329329
 	}
330330
 	return billingdb.New().ListInvoicesForOrg(ctx, deps.Pool, billingdb.ListInvoicesForOrgParams{
331
-		OrgID: orgID,
332
-		Limit: limit,
331
+		// SubjectID equals OrgID by the billing_invoices_org_id_matches_subject
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,
333336
 	})
334337
 }
335338
 
internal/billing/queries/billing.sqlmodified
@@ -243,8 +243,15 @@ WHERE org_id = $1
243243
 -- ─── billing_invoices ──────────────────────────────────────────────
244244
 
245245
 -- 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.
246251
 INSERT INTO billing_invoices (
247252
     org_id,
253
+    subject_kind,
254
+    subject_id,
248255
     provider,
249256
     stripe_invoice_id,
250257
     stripe_customer_id,
@@ -264,6 +271,8 @@ INSERT INTO billing_invoices (
264271
     voided_at
265272
 )
266273
 VALUES (
274
+    sqlc.arg(org_id)::bigint,
275
+    'org'::billing_subject_kind,
267276
     sqlc.arg(org_id)::bigint,
268277
     'stripe',
269278
     sqlc.arg(stripe_invoice_id)::text,
@@ -304,11 +313,26 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
304313
 RETURNING *;
305314
 
306315
 -- 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.
307320
 SELECT * FROM billing_invoices
308
-WHERE org_id = $1
321
+WHERE subject_kind = 'org' AND subject_id = $1
309322
 ORDER BY created_at DESC, id DESC
310323
 LIMIT $2;
311324
 
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
+
312336
 -- ─── billing_webhook_events ────────────────────────────────────────
313337
 
314338
 -- name: CreateWebhookEventReceipt :one
@@ -350,3 +374,194 @@ UPDATE billing_webhook_events
350374
  WHERE provider = 'stripe'
351375
    AND provider_event_id = $1
352376
 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
161161
 	return i, err
162162
 }
163163
 
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
+
164313
 const clearBillingLock = `-- name: ClearBillingLock :one
165314
 WITH state AS (
166315
     UPDATE org_billing_states
@@ -242,6 +391,83 @@ func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (C
242391
 	return i, err
243392
 }
244393
 
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
+
245471
 const countBillableOrgMembers = `-- name: CountBillableOrgMembers :one
246472
 SELECT count(*)::integer
247473
 FROM org_members
@@ -363,7 +589,7 @@ VALUES (
363589
     $4::jsonb
364590
 )
365591
 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
367593
 `
368594
 
369595
 type CreateWebhookEventReceiptParams struct {
@@ -393,6 +619,8 @@ func (q *Queries) CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg Cr
393619
 		&i.ProcessedAt,
394620
 		&i.ProcessError,
395621
 		&i.ProcessingAttempts,
622
+		&i.SubjectKind,
623
+		&i.SubjectID,
396624
 	)
397625
 	return i, err
398626
 }
@@ -504,8 +732,107 @@ func (q *Queries) GetOrgBillingStateByStripeSubscription(ctx context.Context, db
504732
 	return i, err
505733
 }
506734
 
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
+
507834
 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
509836
 WHERE provider = 'stripe'
510837
   AND provider_event_id = $1
511838
 `
@@ -524,24 +851,92 @@ func (q *Queries) GetWebhookEventReceipt(ctx context.Context, db DBTX, providerE
524851
 		&i.ProcessedAt,
525852
 		&i.ProcessError,
526853
 		&i.ProcessingAttempts,
854
+		&i.SubjectKind,
855
+		&i.SubjectID,
527856
 	)
528857
 	return i, err
529858
 }
530859
 
531860
 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
533
-WHERE org_id = $1
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
862
+WHERE subject_kind = 'org' AND subject_id = $1
534863
 ORDER BY created_at DESC, id DESC
535864
 LIMIT $2
536865
 `
537866
 
538867
 type ListInvoicesForOrgParams struct {
539
-	OrgID int64
540
-	Limit int32
868
+	SubjectID int64
869
+	Limit     int32
541870
 }
542871
 
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.
543876
 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)
545940
 	if err != nil {
546941
 		return nil, err
547942
 	}
@@ -571,6 +966,8 @@ func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoi
571966
 			&i.VoidedAt,
572967
 			&i.CreatedAt,
573968
 			&i.UpdatedAt,
969
+			&i.SubjectKind,
970
+			&i.SubjectID,
574971
 		); err != nil {
575972
 			return nil, err
576973
 		}
@@ -844,13 +1241,224 @@ func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPay
8441241
 	return i, err
8451242
 }
8461243
 
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
+
8471455
 const markWebhookEventFailed = `-- name: MarkWebhookEventFailed :one
8481456
 UPDATE billing_webhook_events
8491457
    SET process_error = $2,
8501458
        processing_attempts = processing_attempts + 1
8511459
  WHERE provider = 'stripe'
8521460
    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
8541462
 `
8551463
 
8561464
 type MarkWebhookEventFailedParams struct {
@@ -872,6 +1480,8 @@ func (q *Queries) MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkW
8721480
 		&i.ProcessedAt,
8731481
 		&i.ProcessError,
8741482
 		&i.ProcessingAttempts,
1483
+		&i.SubjectKind,
1484
+		&i.SubjectID,
8751485
 	)
8761486
 	return i, err
8771487
 }
@@ -883,7 +1493,7 @@ UPDATE billing_webhook_events
8831493
        processing_attempts = processing_attempts + 1
8841494
  WHERE provider = 'stripe'
8851495
    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
8871497
 `
8881498
 
8891499
 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
9001510
 		&i.ProcessedAt,
9011511
 		&i.ProcessError,
9021512
 		&i.ProcessingAttempts,
1513
+		&i.SubjectKind,
1514
+		&i.SubjectID,
9031515
 	)
9041516
 	return i, err
9051517
 }
@@ -948,10 +1560,54 @@ func (q *Queries) SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeC
9481560
 	return i, err
9491561
 }
9501562
 
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
+
9511605
 const upsertInvoice = `-- name: UpsertInvoice :one
9521606
 
9531607
 INSERT INTO billing_invoices (
9541608
     org_id,
1609
+    subject_kind,
1610
+    subject_id,
9551611
     provider,
9561612
     stripe_invoice_id,
9571613
     stripe_customer_id,
@@ -971,6 +1627,8 @@ INSERT INTO billing_invoices (
9711627
     voided_at
9721628
 )
9731629
 VALUES (
1630
+    $1::bigint,
1631
+    'org'::billing_subject_kind,
9741632
     $1::bigint,
9751633
     'stripe',
9761634
     $2::text,
@@ -1008,7 +1666,7 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
10081666
        paid_at = EXCLUDED.paid_at,
10091667
        voided_at = EXCLUDED.voided_at,
10101668
        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
10121670
 `
10131671
 
10141672
 type UpsertInvoiceParams struct {
@@ -1032,6 +1690,11 @@ type UpsertInvoiceParams struct {
10321690
 }
10331691
 
10341692
 // ─── 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.
10351698
 func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error) {
10361699
 	row := db.QueryRow(ctx, upsertInvoice,
10371700
 		arg.OrgID,
@@ -1075,6 +1738,8 @@ func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceP
10751738
 		&i.VoidedAt,
10761739
 		&i.CreatedAt,
10771740
 		&i.UpdatedAt,
1741
+		&i.SubjectKind,
1742
+		&i.SubjectID,
10781743
 	)
10791744
 	return i, err
10801745
 }
internal/billing/sqlc/querier.gomodified
@@ -12,7 +12,12 @@ import (
1212
 
1313
 type Querier interface {
1414
 	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)
1519
 	ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (ClearBillingLockRow, error)
20
+	ClearUserBillingLock(ctx context.Context, db DBTX, userID int64) (ClearUserBillingLockRow, error)
1621
 	CountBillableOrgMembers(ctx context.Context, db DBTX, orgID int64) (int32, error)
1722
 	CountPendingOrgInvitations(ctx context.Context, db DBTX, orgID int64) (int32, error)
1823
 	// ─── billing_seat_snapshots ────────────────────────────────────────
@@ -24,16 +29,38 @@ type Querier interface {
2429
 	GetOrgBillingState(ctx context.Context, db DBTX, orgID int64) (OrgBillingState, error)
2530
 	GetOrgBillingStateByStripeCustomer(ctx context.Context, db DBTX, stripeCustomerID pgtype.Text) (OrgBillingState, error)
2631
 	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)
2736
 	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.
2841
 	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)
2947
 	ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error)
3048
 	MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error)
3149
 	MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error)
3250
 	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)
3354
 	MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkWebhookEventFailedParams) (BillingWebhookEvent, error)
3455
 	MarkWebhookEventProcessed(ctx context.Context, db DBTX, providerEventID string) (BillingWebhookEvent, error)
3556
 	SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeCustomerParams) (OrgBillingState, error)
57
+	SetUserStripeCustomer(ctx context.Context, db DBTX, arg SetUserStripeCustomerParams) (UserBillingState, error)
3658
 	// ─── 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.
3764
 	UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error)
3865
 }
3966