tenseleyflow/shithub / 2143ef5

Browse files

billing: Principal-shaped service surface (Resolve, Apply, Mark, Upsert, List)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2143ef58f11e52cbc909dcf3762a3443ddc53a55
Parents
371e7e3
Tree
6f0a8ef

1 changed file

StatusFile+-
A internal/billing/principal_ops.go 556 0
internal/billing/principal_ops.goadded
@@ -0,0 +1,556 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package billing
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
16
+)
17
+
18
+// PrincipalState is the unified projection of a subject's billing
19
+// state that the webhook handler and Principal-shaped service
20
+// callers consume. It carries only the fields used for routing /
21
+// transition decisions; full org or user state stays accessible
22
+// via GetOrgBillingState / GetUserBillingState when the caller
23
+// already knows the kind and wants table-specific columns.
24
+type PrincipalState struct {
25
+	Principal            Principal
26
+	Plan                 string // user_plan or org_plan, narrowed to string for kind-agnostic logging
27
+	SubscriptionStatus   SubscriptionStatus
28
+	StripeCustomerID     string
29
+	StripeSubscriptionID string
30
+	CancelAtPeriodEnd    bool
31
+	LockedAt             time.Time
32
+}
33
+
34
+// ErrPrincipalNotFound signals that no row matched a
35
+// Stripe-customer-id or subscription-id lookup on either table.
36
+// Callers translate to a user-visible error or fall through to the
37
+// metadata-resolution path.
38
+var ErrPrincipalNotFound = errors.New("billing: principal not found")
39
+
40
+// GetStateForPrincipal returns the unified state for `p`. Branches
41
+// to org or user sqlc query based on p.Kind. Surfaces
42
+// ErrInvalidPrincipal for malformed input; pgx.ErrNoRows for
43
+// missing rows (caller's responsibility to handle).
44
+func GetStateForPrincipal(ctx context.Context, deps Deps, p Principal) (PrincipalState, error) {
45
+	if err := validateDeps(deps); err != nil {
46
+		return PrincipalState{}, err
47
+	}
48
+	if err := p.Validate(); err != nil {
49
+		return PrincipalState{}, err
50
+	}
51
+	q := billingdb.New()
52
+	switch p.Kind {
53
+	case SubjectKindOrg:
54
+		state, err := q.GetOrgBillingState(ctx, deps.Pool, p.ID)
55
+		if err != nil {
56
+			return PrincipalState{}, err
57
+		}
58
+		return principalStateFromOrg(state), nil
59
+	case SubjectKindUser:
60
+		state, err := q.GetUserBillingState(ctx, deps.Pool, p.ID)
61
+		if err != nil {
62
+			return PrincipalState{}, err
63
+		}
64
+		return principalStateFromUser(state), nil
65
+	default:
66
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
67
+	}
68
+}
69
+
70
+// ResolvePrincipalByStripeCustomer searches both billing-state
71
+// tables for `customerID`. Stripe customer-ids are globally unique
72
+// per Stripe account, so at most one row matches; we check user
73
+// table first (newer, smaller during launch) then org as a small
74
+// optimization. Cross-table duplicate is impossible by the
75
+// unique-index design; if one ever appears, this returns the first
76
+// hit and a defensive caller in the webhook handler should refuse
77
+// the apply with a loud log.
78
+func ResolvePrincipalByStripeCustomer(ctx context.Context, deps Deps, customerID string) (PrincipalState, error) {
79
+	if err := validateDeps(deps); err != nil {
80
+		return PrincipalState{}, err
81
+	}
82
+	customerID = strings.TrimSpace(customerID)
83
+	if customerID == "" {
84
+		return PrincipalState{}, ErrStripeCustomerID
85
+	}
86
+	q := billingdb.New()
87
+	if user, err := q.GetUserBillingStateByStripeCustomer(ctx, deps.Pool, pgText(customerID)); err == nil {
88
+		return principalStateFromUser(user), nil
89
+	} else if !errors.Is(err, pgx.ErrNoRows) {
90
+		return PrincipalState{}, err
91
+	}
92
+	if org, err := q.GetOrgBillingStateByStripeCustomer(ctx, deps.Pool, pgText(customerID)); err == nil {
93
+		return principalStateFromOrg(org), nil
94
+	} else if !errors.Is(err, pgx.ErrNoRows) {
95
+		return PrincipalState{}, err
96
+	}
97
+	return PrincipalState{}, ErrPrincipalNotFound
98
+}
99
+
100
+// ResolvePrincipalByStripeSubscription is the subscription-id
101
+// counterpart. Same dual-table search; same uniqueness guarantee.
102
+func ResolvePrincipalByStripeSubscription(ctx context.Context, deps Deps, subID string) (PrincipalState, error) {
103
+	if err := validateDeps(deps); err != nil {
104
+		return PrincipalState{}, err
105
+	}
106
+	subID = strings.TrimSpace(subID)
107
+	if subID == "" {
108
+		return PrincipalState{}, ErrStripeSubscriptionID
109
+	}
110
+	q := billingdb.New()
111
+	if user, err := q.GetUserBillingStateByStripeSubscription(ctx, deps.Pool, pgText(subID)); err == nil {
112
+		return principalStateFromUser(user), nil
113
+	} else if !errors.Is(err, pgx.ErrNoRows) {
114
+		return PrincipalState{}, err
115
+	}
116
+	if org, err := q.GetOrgBillingStateByStripeSubscription(ctx, deps.Pool, pgText(subID)); err == nil {
117
+		return principalStateFromOrg(org), nil
118
+	} else if !errors.Is(err, pgx.ErrNoRows) {
119
+		return PrincipalState{}, err
120
+	}
121
+	return PrincipalState{}, ErrPrincipalNotFound
122
+}
123
+
124
+// SetStripeCustomerForPrincipal binds a Stripe customer id to either
125
+// the org or user billing state. The org-shaped SetStripeCustomer
126
+// stays as a thin wrapper for callers that pre-date PRO04.
127
+func SetStripeCustomerForPrincipal(ctx context.Context, deps Deps, p Principal, customerID string) (PrincipalState, error) {
128
+	if err := validateDeps(deps); err != nil {
129
+		return PrincipalState{}, err
130
+	}
131
+	if err := p.Validate(); err != nil {
132
+		return PrincipalState{}, err
133
+	}
134
+	customerID = strings.TrimSpace(customerID)
135
+	if customerID == "" {
136
+		return PrincipalState{}, ErrStripeCustomerID
137
+	}
138
+	q := billingdb.New()
139
+	switch p.Kind {
140
+	case SubjectKindOrg:
141
+		state, err := q.SetStripeCustomer(ctx, deps.Pool, billingdb.SetStripeCustomerParams{
142
+			OrgID:            p.ID,
143
+			StripeCustomerID: pgText(customerID),
144
+		})
145
+		if err != nil {
146
+			return PrincipalState{}, err
147
+		}
148
+		return principalStateFromOrg(state), nil
149
+	case SubjectKindUser:
150
+		state, err := q.SetUserStripeCustomer(ctx, deps.Pool, billingdb.SetUserStripeCustomerParams{
151
+			UserID:           p.ID,
152
+			StripeCustomerID: pgText(customerID),
153
+		})
154
+		if err != nil {
155
+			return PrincipalState{}, err
156
+		}
157
+		return principalStateFromUser(state), nil
158
+	default:
159
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
160
+	}
161
+}
162
+
163
+// PrincipalSubscriptionSnapshot is the kind-agnostic snapshot
164
+// passed to ApplySubscriptionSnapshotForPrincipal. The webhook
165
+// handler builds this from the resolved Principal + Stripe event;
166
+// the kind-specific plan (Pro for user, Team for org) is set by
167
+// the caller before passing in.
168
+type PrincipalSubscriptionSnapshot struct {
169
+	Principal                Principal
170
+	Status                   SubscriptionStatus
171
+	StripeSubscriptionID     string
172
+	StripeSubscriptionItemID string
173
+	CurrentPeriodStart       time.Time
174
+	CurrentPeriodEnd         time.Time
175
+	CancelAtPeriodEnd        bool
176
+	TrialEnd                 time.Time
177
+	CanceledAt               time.Time
178
+	LastWebhookEventID       string
179
+}
180
+
181
+// ApplySubscriptionSnapshotForPrincipal routes the snapshot to
182
+// either the org or user sqlc apply query. The plan it writes is
183
+// `team` for org kind, `pro` for user kind — there is no third
184
+// option in PRO04 (Enterprise stays contact-sales).
185
+func ApplySubscriptionSnapshotForPrincipal(ctx context.Context, deps Deps, snap PrincipalSubscriptionSnapshot) (PrincipalState, error) {
186
+	if err := validateDeps(deps); err != nil {
187
+		return PrincipalState{}, err
188
+	}
189
+	if err := snap.Principal.Validate(); err != nil {
190
+		return PrincipalState{}, err
191
+	}
192
+	if !validStatus(snap.Status) {
193
+		return PrincipalState{}, fmt.Errorf("%w: %q", ErrInvalidStatus, snap.Status)
194
+	}
195
+	q := billingdb.New()
196
+	switch snap.Principal.Kind {
197
+	case SubjectKindOrg:
198
+		row, err := q.ApplySubscriptionSnapshot(ctx, deps.Pool, billingdb.ApplySubscriptionSnapshotParams{
199
+			OrgID:                    snap.Principal.ID,
200
+			Plan:                     billingdb.OrgPlanTeam,
201
+			SubscriptionStatus:       snap.Status,
202
+			StripeSubscriptionID:     pgText(snap.StripeSubscriptionID),
203
+			StripeSubscriptionItemID: pgText(snap.StripeSubscriptionItemID),
204
+			CurrentPeriodStart:       pgTime(snap.CurrentPeriodStart),
205
+			CurrentPeriodEnd:         pgTime(snap.CurrentPeriodEnd),
206
+			CancelAtPeriodEnd:        snap.CancelAtPeriodEnd,
207
+			TrialEnd:                 pgTime(snap.TrialEnd),
208
+			CanceledAt:               pgTime(snap.CanceledAt),
209
+			LastWebhookEventID:       strings.TrimSpace(snap.LastWebhookEventID),
210
+		})
211
+		if err != nil {
212
+			return PrincipalState{}, err
213
+		}
214
+		return principalStateFromOrgApply(row), nil
215
+	case SubjectKindUser:
216
+		row, err := q.ApplyUserSubscriptionSnapshot(ctx, deps.Pool, billingdb.ApplyUserSubscriptionSnapshotParams{
217
+			UserID:                   snap.Principal.ID,
218
+			Plan:                     billingdb.UserPlanPro,
219
+			SubscriptionStatus:       snap.Status,
220
+			StripeSubscriptionID:     pgText(snap.StripeSubscriptionID),
221
+			StripeSubscriptionItemID: pgText(snap.StripeSubscriptionItemID),
222
+			CurrentPeriodStart:       pgTime(snap.CurrentPeriodStart),
223
+			CurrentPeriodEnd:         pgTime(snap.CurrentPeriodEnd),
224
+			CancelAtPeriodEnd:        snap.CancelAtPeriodEnd,
225
+			TrialEnd:                 pgTime(snap.TrialEnd),
226
+			CanceledAt:               pgTime(snap.CanceledAt),
227
+			LastWebhookEventID:       strings.TrimSpace(snap.LastWebhookEventID),
228
+		})
229
+		if err != nil {
230
+			return PrincipalState{}, err
231
+		}
232
+		return principalStateFromUserApply(row), nil
233
+	default:
234
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, snap.Principal.Kind)
235
+	}
236
+}
237
+
238
+// MarkPastDueForPrincipal flips either org or user state to
239
+// past_due. Mirrors the org-shaped MarkPastDue exactly; the user
240
+// branch hits MarkUserPastDue.
241
+func MarkPastDueForPrincipal(ctx context.Context, deps Deps, p Principal, graceUntil time.Time, eventID string) (PrincipalState, error) {
242
+	if err := validateDeps(deps); err != nil {
243
+		return PrincipalState{}, err
244
+	}
245
+	if err := p.Validate(); err != nil {
246
+		return PrincipalState{}, err
247
+	}
248
+	eventID = strings.TrimSpace(eventID)
249
+	if eventID == "" {
250
+		return PrincipalState{}, ErrWebhookEventID
251
+	}
252
+	q := billingdb.New()
253
+	switch p.Kind {
254
+	case SubjectKindOrg:
255
+		state, err := q.MarkPastDue(ctx, deps.Pool, billingdb.MarkPastDueParams{
256
+			OrgID:              p.ID,
257
+			GraceUntil:         pgTime(graceUntil),
258
+			LastWebhookEventID: eventID,
259
+		})
260
+		if err != nil {
261
+			return PrincipalState{}, err
262
+		}
263
+		return principalStateFromOrg(state), nil
264
+	case SubjectKindUser:
265
+		state, err := q.MarkUserPastDue(ctx, deps.Pool, billingdb.MarkUserPastDueParams{
266
+			UserID:             p.ID,
267
+			GraceUntil:         pgTime(graceUntil),
268
+			LastWebhookEventID: eventID,
269
+		})
270
+		if err != nil {
271
+			return PrincipalState{}, err
272
+		}
273
+		return principalStateFromUser(state), nil
274
+	default:
275
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
276
+	}
277
+}
278
+
279
+// MarkCanceledForPrincipal flips either org or user state to
280
+// canceled+free. The user-tier MarkUserCanceled atomically updates
281
+// users.plan='free' via its CTE; the org analog does the same on
282
+// orgs.plan.
283
+func MarkCanceledForPrincipal(ctx context.Context, deps Deps, p Principal, eventID string) (PrincipalState, error) {
284
+	if err := validateDeps(deps); err != nil {
285
+		return PrincipalState{}, err
286
+	}
287
+	if err := p.Validate(); err != nil {
288
+		return PrincipalState{}, err
289
+	}
290
+	eventID = strings.TrimSpace(eventID)
291
+	if eventID == "" {
292
+		return PrincipalState{}, ErrWebhookEventID
293
+	}
294
+	q := billingdb.New()
295
+	switch p.Kind {
296
+	case SubjectKindOrg:
297
+		row, err := q.MarkCanceled(ctx, deps.Pool, billingdb.MarkCanceledParams{
298
+			OrgID:              p.ID,
299
+			LastWebhookEventID: eventID,
300
+		})
301
+		if err != nil {
302
+			return PrincipalState{}, err
303
+		}
304
+		return principalStateFromOrgCanceled(row), nil
305
+	case SubjectKindUser:
306
+		row, err := q.MarkUserCanceled(ctx, deps.Pool, billingdb.MarkUserCanceledParams{
307
+			UserID:             p.ID,
308
+			LastWebhookEventID: eventID,
309
+		})
310
+		if err != nil {
311
+			return PrincipalState{}, err
312
+		}
313
+		return principalStateFromUserCanceled(row), nil
314
+	default:
315
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
316
+	}
317
+}
318
+
319
+// MarkPaymentSucceededForPrincipal recovers either org or user from
320
+// past_due/incomplete/unpaid back to active.
321
+func MarkPaymentSucceededForPrincipal(ctx context.Context, deps Deps, p Principal, eventID string) (PrincipalState, error) {
322
+	if err := validateDeps(deps); err != nil {
323
+		return PrincipalState{}, err
324
+	}
325
+	if err := p.Validate(); err != nil {
326
+		return PrincipalState{}, err
327
+	}
328
+	eventID = strings.TrimSpace(eventID)
329
+	if eventID == "" {
330
+		return PrincipalState{}, ErrWebhookEventID
331
+	}
332
+	q := billingdb.New()
333
+	switch p.Kind {
334
+	case SubjectKindOrg:
335
+		row, err := q.MarkPaymentSucceeded(ctx, deps.Pool, billingdb.MarkPaymentSucceededParams{
336
+			OrgID:              p.ID,
337
+			LastWebhookEventID: eventID,
338
+		})
339
+		if err != nil {
340
+			return PrincipalState{}, err
341
+		}
342
+		return principalStateFromOrgPaymentSucceeded(row), nil
343
+	case SubjectKindUser:
344
+		row, err := q.MarkUserPaymentSucceeded(ctx, deps.Pool, billingdb.MarkUserPaymentSucceededParams{
345
+			UserID:             p.ID,
346
+			LastWebhookEventID: eventID,
347
+		})
348
+		if err != nil {
349
+			return PrincipalState{}, err
350
+		}
351
+		return principalStateFromUserPaymentSucceeded(row), nil
352
+	default:
353
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
354
+	}
355
+}
356
+
357
+// ClearBillingLockForPrincipal clears the lock columns and returns
358
+// state to none/free as appropriate. Useful for operator-driven
359
+// recovery scenarios.
360
+func ClearBillingLockForPrincipal(ctx context.Context, deps Deps, p Principal) (PrincipalState, error) {
361
+	if err := validateDeps(deps); err != nil {
362
+		return PrincipalState{}, err
363
+	}
364
+	if err := p.Validate(); err != nil {
365
+		return PrincipalState{}, err
366
+	}
367
+	q := billingdb.New()
368
+	switch p.Kind {
369
+	case SubjectKindOrg:
370
+		row, err := q.ClearBillingLock(ctx, deps.Pool, p.ID)
371
+		if err != nil {
372
+			return PrincipalState{}, err
373
+		}
374
+		return principalStateFromOrgClear(row), nil
375
+	case SubjectKindUser:
376
+		row, err := q.ClearUserBillingLock(ctx, deps.Pool, p.ID)
377
+		if err != nil {
378
+			return PrincipalState{}, err
379
+		}
380
+		return principalStateFromUserClear(row), nil
381
+	default:
382
+		return PrincipalState{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
383
+	}
384
+}
385
+
386
+// UpsertInvoiceForPrincipal writes an invoice row keyed by the
387
+// resolved subject. The polymorphic billing_invoices schema makes
388
+// the org and user paths identical at the SQL level; this helper
389
+// exists so callers don't reach into the sqlc struct field naming
390
+// drift between OrgID and SubjectID.
391
+func UpsertInvoiceForPrincipal(ctx context.Context, deps Deps, p Principal, snap InvoiceSnapshot) (billingdb.BillingInvoice, error) {
392
+	if err := validateDeps(deps); err != nil {
393
+		return billingdb.BillingInvoice{}, err
394
+	}
395
+	if err := p.Validate(); err != nil {
396
+		return billingdb.BillingInvoice{}, err
397
+	}
398
+	snap.StripeInvoiceID = strings.TrimSpace(snap.StripeInvoiceID)
399
+	if snap.StripeInvoiceID == "" {
400
+		return billingdb.BillingInvoice{}, ErrStripeInvoiceID
401
+	}
402
+	snap.StripeCustomerID = strings.TrimSpace(snap.StripeCustomerID)
403
+	if snap.StripeCustomerID == "" {
404
+		return billingdb.BillingInvoice{}, ErrStripeCustomerID
405
+	}
406
+	if !validInvoiceStatus(snap.Status) {
407
+		return billingdb.BillingInvoice{}, fmt.Errorf("%w: %q", ErrInvalidInvoiceStatus, snap.Status)
408
+	}
409
+	// The existing UpsertInvoice sqlc query writes both org_id and
410
+	// (subject_kind, subject_id) from the same `org_id` arg per the
411
+	// 0074 migration's two-step deploy. For user kind we need a
412
+	// polymorphic upsert that DOES NOT write org_id — that surface
413
+	// is added in this sprint as a sibling query when needed. For
414
+	// PRO04 the only user-kind invoice writes come from the webhook
415
+	// handler; org_id stays NULL for those rows per the migration's
416
+	// nullable change.
417
+	switch p.Kind {
418
+	case SubjectKindOrg:
419
+		return billingdb.New().UpsertInvoice(ctx, deps.Pool, billingdb.UpsertInvoiceParams{
420
+			OrgID:                p.ID,
421
+			StripeInvoiceID:      snap.StripeInvoiceID,
422
+			StripeCustomerID:     snap.StripeCustomerID,
423
+			StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
424
+			Status:               snap.Status,
425
+			Number:               strings.TrimSpace(snap.Number),
426
+			Currency:             strings.ToLower(strings.TrimSpace(snap.Currency)),
427
+			AmountDueCents:       snap.AmountDueCents,
428
+			AmountPaidCents:      snap.AmountPaidCents,
429
+			AmountRemainingCents: snap.AmountRemainingCents,
430
+			HostedInvoiceUrl:     strings.TrimSpace(snap.HostedInvoiceURL),
431
+			InvoicePdfUrl:        strings.TrimSpace(snap.InvoicePDFURL),
432
+			PeriodStart:          pgTime(snap.PeriodStart),
433
+			PeriodEnd:            pgTime(snap.PeriodEnd),
434
+			DueAt:                pgTime(snap.DueAt),
435
+			PaidAt:               pgTime(snap.PaidAt),
436
+			VoidedAt:             pgTime(snap.VoidedAt),
437
+		})
438
+	case SubjectKindUser:
439
+		return billingdb.New().UpsertInvoiceForSubject(ctx, deps.Pool, billingdb.UpsertInvoiceForSubjectParams{
440
+			SubjectKind:          billingdb.BillingSubjectKindUser,
441
+			SubjectID:            p.ID,
442
+			StripeInvoiceID:      snap.StripeInvoiceID,
443
+			StripeCustomerID:     snap.StripeCustomerID,
444
+			StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
445
+			Status:               snap.Status,
446
+			Number:               strings.TrimSpace(snap.Number),
447
+			Currency:             strings.ToLower(strings.TrimSpace(snap.Currency)),
448
+			AmountDueCents:       snap.AmountDueCents,
449
+			AmountPaidCents:      snap.AmountPaidCents,
450
+			AmountRemainingCents: snap.AmountRemainingCents,
451
+			HostedInvoiceUrl:     strings.TrimSpace(snap.HostedInvoiceURL),
452
+			InvoicePdfUrl:        strings.TrimSpace(snap.InvoicePDFURL),
453
+			PeriodStart:          pgTime(snap.PeriodStart),
454
+			PeriodEnd:            pgTime(snap.PeriodEnd),
455
+			DueAt:                pgTime(snap.DueAt),
456
+			PaidAt:               pgTime(snap.PaidAt),
457
+			VoidedAt:             pgTime(snap.VoidedAt),
458
+		})
459
+	default:
460
+		return billingdb.BillingInvoice{}, fmt.Errorf("%w: kind %q", ErrInvalidPrincipal, p.Kind)
461
+	}
462
+}
463
+
464
+// ListInvoicesForPrincipal reads the polymorphic billing_invoices
465
+// table for a given subject. The existing org-shaped
466
+// ListInvoicesForOrg already filters on (subject_kind='org',
467
+// subject_id=$1) under the hood (per the PRO03 query rewrite); the
468
+// kind-agnostic surface here is for PRO04+ callers (the user-side
469
+// settings page in PRO06) that don't want to bind a kind literally.
470
+func ListInvoicesForPrincipal(ctx context.Context, deps Deps, p Principal, limit int32) ([]billingdb.BillingInvoice, error) {
471
+	if err := validateDeps(deps); err != nil {
472
+		return nil, err
473
+	}
474
+	if err := p.Validate(); err != nil {
475
+		return nil, err
476
+	}
477
+	if limit <= 0 {
478
+		limit = 10
479
+	}
480
+	return billingdb.New().ListInvoicesForSubject(ctx, deps.Pool, billingdb.ListInvoicesForSubjectParams{
481
+		SubjectKind: billingdb.BillingSubjectKind(p.Kind),
482
+		SubjectID:   p.ID,
483
+		Lim:         limit,
484
+	})
485
+}
486
+
487
+// ─── internal projections ─────────────────────────────────────────
488
+
489
+func principalStateFromOrg(row billingdb.OrgBillingState) PrincipalState {
490
+	out := PrincipalState{
491
+		Principal:          Principal{Kind: SubjectKindOrg, ID: row.OrgID},
492
+		Plan:               string(row.Plan),
493
+		SubscriptionStatus: row.SubscriptionStatus,
494
+		CancelAtPeriodEnd:  row.CancelAtPeriodEnd,
495
+	}
496
+	out.StripeCustomerID = pgTextValue(row.StripeCustomerID)
497
+	out.StripeSubscriptionID = pgTextValue(row.StripeSubscriptionID)
498
+	if row.LockedAt.Valid {
499
+		out.LockedAt = row.LockedAt.Time
500
+	}
501
+	return out
502
+}
503
+
504
+func principalStateFromUser(row billingdb.UserBillingState) PrincipalState {
505
+	out := PrincipalState{
506
+		Principal:          Principal{Kind: SubjectKindUser, ID: row.UserID},
507
+		Plan:               string(row.Plan),
508
+		SubscriptionStatus: row.SubscriptionStatus,
509
+		CancelAtPeriodEnd:  row.CancelAtPeriodEnd,
510
+	}
511
+	out.StripeCustomerID = pgTextValue(row.StripeCustomerID)
512
+	out.StripeSubscriptionID = pgTextValue(row.StripeSubscriptionID)
513
+	if row.LockedAt.Valid {
514
+		out.LockedAt = row.LockedAt.Time
515
+	}
516
+	return out
517
+}
518
+
519
+func principalStateFromOrgApply(row billingdb.ApplySubscriptionSnapshotRow) PrincipalState {
520
+	return principalStateFromOrg(billingdb.OrgBillingState(row))
521
+}
522
+
523
+func principalStateFromUserApply(row billingdb.ApplyUserSubscriptionSnapshotRow) PrincipalState {
524
+	return principalStateFromUser(billingdb.UserBillingState(row))
525
+}
526
+
527
+func principalStateFromOrgCanceled(row billingdb.MarkCanceledRow) PrincipalState {
528
+	return principalStateFromOrg(billingdb.OrgBillingState(row))
529
+}
530
+
531
+func principalStateFromUserCanceled(row billingdb.MarkUserCanceledRow) PrincipalState {
532
+	return principalStateFromUser(billingdb.UserBillingState(row))
533
+}
534
+
535
+func principalStateFromOrgPaymentSucceeded(row billingdb.MarkPaymentSucceededRow) PrincipalState {
536
+	return principalStateFromOrg(billingdb.OrgBillingState(row))
537
+}
538
+
539
+func principalStateFromUserPaymentSucceeded(row billingdb.MarkUserPaymentSucceededRow) PrincipalState {
540
+	return principalStateFromUser(billingdb.UserBillingState(row))
541
+}
542
+
543
+func principalStateFromOrgClear(row billingdb.ClearBillingLockRow) PrincipalState {
544
+	return principalStateFromOrg(billingdb.OrgBillingState(row))
545
+}
546
+
547
+func principalStateFromUserClear(row billingdb.ClearUserBillingLockRow) PrincipalState {
548
+	return principalStateFromUser(billingdb.UserBillingState(row))
549
+}
550
+
551
+func pgTextValue(t pgtype.Text) string {
552
+	if !t.Valid {
553
+		return ""
554
+	}
555
+	return t.String
556
+}