Go · 20844 bytes Raw Blame History
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 }
557