@@ -161,6 +161,155 @@ func (q *Queries) ApplySubscriptionSnapshot(ctx context.Context, db DBTX, arg Ap |
| 161 | 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 | 313 | const clearBillingLock = `-- name: ClearBillingLock :one |
| 165 | 314 | WITH state AS ( |
| 166 | 315 | UPDATE org_billing_states |
@@ -242,6 +391,83 @@ func (q *Queries) ClearBillingLock(ctx context.Context, db DBTX, orgID int64) (C |
| 242 | 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 | 471 | const countBillableOrgMembers = `-- name: CountBillableOrgMembers :one |
| 246 | 472 | SELECT count(*)::integer |
| 247 | 473 | FROM org_members |
@@ -363,7 +589,7 @@ VALUES ( |
| 363 | 589 | $4::jsonb |
| 364 | 590 | ) |
| 365 | 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 | 595 | type CreateWebhookEventReceiptParams struct { |
@@ -393,6 +619,8 @@ func (q *Queries) CreateWebhookEventReceipt(ctx context.Context, db DBTX, arg Cr |
| 393 | 619 | &i.ProcessedAt, |
| 394 | 620 | &i.ProcessError, |
| 395 | 621 | &i.ProcessingAttempts, |
| 622 | + &i.SubjectKind, |
| 623 | + &i.SubjectID, |
| 396 | 624 | ) |
| 397 | 625 | return i, err |
| 398 | 626 | } |
@@ -504,8 +732,107 @@ func (q *Queries) GetOrgBillingStateByStripeSubscription(ctx context.Context, db |
| 504 | 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 | 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 | 836 | WHERE provider = 'stripe' |
| 510 | 837 | AND provider_event_id = $1 |
| 511 | 838 | ` |
@@ -524,24 +851,92 @@ func (q *Queries) GetWebhookEventReceipt(ctx context.Context, db DBTX, providerE |
| 524 | 851 | &i.ProcessedAt, |
| 525 | 852 | &i.ProcessError, |
| 526 | 853 | &i.ProcessingAttempts, |
| 854 | + &i.SubjectKind, |
| 855 | + &i.SubjectID, |
| 527 | 856 | ) |
| 528 | 857 | return i, err |
| 529 | 858 | } |
| 530 | 859 | |
| 531 | 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 |
| 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 |
| 534 | 863 | ORDER BY created_at DESC, id DESC |
| 535 | 864 | LIMIT $2 |
| 536 | 865 | ` |
| 537 | 866 | |
| 538 | 867 | type ListInvoicesForOrgParams struct { |
| 539 | | - OrgID int64 |
| 540 | | - Limit int32 |
| 868 | + SubjectID int64 |
| 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 | 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 | 940 | if err != nil { |
| 546 | 941 | return nil, err |
| 547 | 942 | } |
@@ -571,6 +966,8 @@ func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoi |
| 571 | 966 | &i.VoidedAt, |
| 572 | 967 | &i.CreatedAt, |
| 573 | 968 | &i.UpdatedAt, |
| 969 | + &i.SubjectKind, |
| 970 | + &i.SubjectID, |
| 574 | 971 | ); err != nil { |
| 575 | 972 | return nil, err |
| 576 | 973 | } |
@@ -844,13 +1241,224 @@ func (q *Queries) MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPay |
| 844 | 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 | 1455 | const markWebhookEventFailed = `-- name: MarkWebhookEventFailed :one |
| 848 | 1456 | UPDATE billing_webhook_events |
| 849 | 1457 | SET process_error = $2, |
| 850 | 1458 | processing_attempts = processing_attempts + 1 |
| 851 | 1459 | WHERE provider = 'stripe' |
| 852 | 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 | 1464 | type MarkWebhookEventFailedParams struct { |
@@ -872,6 +1480,8 @@ func (q *Queries) MarkWebhookEventFailed(ctx context.Context, db DBTX, arg MarkW |
| 872 | 1480 | &i.ProcessedAt, |
| 873 | 1481 | &i.ProcessError, |
| 874 | 1482 | &i.ProcessingAttempts, |
| 1483 | + &i.SubjectKind, |
| 1484 | + &i.SubjectID, |
| 875 | 1485 | ) |
| 876 | 1486 | return i, err |
| 877 | 1487 | } |
@@ -883,7 +1493,7 @@ UPDATE billing_webhook_events |
| 883 | 1493 | processing_attempts = processing_attempts + 1 |
| 884 | 1494 | WHERE provider = 'stripe' |
| 885 | 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 | 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 | 1510 | &i.ProcessedAt, |
| 901 | 1511 | &i.ProcessError, |
| 902 | 1512 | &i.ProcessingAttempts, |
| 1513 | + &i.SubjectKind, |
| 1514 | + &i.SubjectID, |
| 903 | 1515 | ) |
| 904 | 1516 | return i, err |
| 905 | 1517 | } |
@@ -948,10 +1560,54 @@ func (q *Queries) SetStripeCustomer(ctx context.Context, db DBTX, arg SetStripeC |
| 948 | 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 | 1605 | const upsertInvoice = `-- name: UpsertInvoice :one |
| 952 | 1606 | |
| 953 | 1607 | INSERT INTO billing_invoices ( |
| 954 | 1608 | org_id, |
| 1609 | + subject_kind, |
| 1610 | + subject_id, |
| 955 | 1611 | provider, |
| 956 | 1612 | stripe_invoice_id, |
| 957 | 1613 | stripe_customer_id, |
@@ -971,6 +1627,8 @@ INSERT INTO billing_invoices ( |
| 971 | 1627 | voided_at |
| 972 | 1628 | ) |
| 973 | 1629 | VALUES ( |
| 1630 | + $1::bigint, |
| 1631 | + 'org'::billing_subject_kind, |
| 974 | 1632 | $1::bigint, |
| 975 | 1633 | 'stripe', |
| 976 | 1634 | $2::text, |
@@ -1008,7 +1666,7 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE |
| 1008 | 1666 | paid_at = EXCLUDED.paid_at, |
| 1009 | 1667 | voided_at = EXCLUDED.voided_at, |
| 1010 | 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 | 1672 | type UpsertInvoiceParams struct { |
@@ -1032,6 +1690,11 @@ type UpsertInvoiceParams struct { |
| 1032 | 1690 | } |
| 1033 | 1691 | |
| 1034 | 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 | 1698 | func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceParams) (BillingInvoice, error) { |
| 1036 | 1699 | row := db.QueryRow(ctx, upsertInvoice, |
| 1037 | 1700 | arg.OrgID, |
@@ -1075,6 +1738,8 @@ func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceP |
| 1075 | 1738 | &i.VoidedAt, |
| 1076 | 1739 | &i.CreatedAt, |
| 1077 | 1740 | &i.UpdatedAt, |
| 1741 | + &i.SubjectKind, |
| 1742 | + &i.SubjectID, |
| 1078 | 1743 | ) |
| 1079 | 1744 | return i, err |
| 1080 | 1745 | } |