Go · 22943 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package billing owns local paid-organization state. It stores Stripe
4 // identifiers and derived subscription state, but it does not call
5 // Stripe directly; Stripe API details stay in the SP03 adapter layer.
6 package billing
7
8 import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "strings"
14 "time"
15
16 "github.com/jackc/pgx/v5"
17 "github.com/jackc/pgx/v5/pgtype"
18 "github.com/jackc/pgx/v5/pgxpool"
19
20 billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
21 )
22
23 type Deps struct {
24 Pool *pgxpool.Pool
25 }
26
27 type (
28 Plan = billingdb.OrgPlan
29 UserPlan = billingdb.UserPlan
30 SubscriptionStatus = billingdb.BillingSubscriptionStatus
31 InvoiceStatus = billingdb.BillingInvoiceStatus
32 State = billingdb.OrgBillingState
33 UserState = billingdb.UserBillingState
34 )
35
36 const (
37 PlanFree = billingdb.OrgPlanFree
38 PlanTeam = billingdb.OrgPlanTeam
39 PlanEnterprise = billingdb.OrgPlanEnterprise
40
41 UserPlanFree = billingdb.UserPlanFree
42 UserPlanPro = billingdb.UserPlanPro
43
44 SubscriptionStatusNone = billingdb.BillingSubscriptionStatusNone
45 SubscriptionStatusIncomplete = billingdb.BillingSubscriptionStatusIncomplete
46 SubscriptionStatusTrialing = billingdb.BillingSubscriptionStatusTrialing
47 SubscriptionStatusActive = billingdb.BillingSubscriptionStatusActive
48 SubscriptionStatusPastDue = billingdb.BillingSubscriptionStatusPastDue
49 SubscriptionStatusCanceled = billingdb.BillingSubscriptionStatusCanceled
50 SubscriptionStatusUnpaid = billingdb.BillingSubscriptionStatusUnpaid
51 SubscriptionStatusPaused = billingdb.BillingSubscriptionStatusPaused
52
53 InvoiceStatusDraft = billingdb.BillingInvoiceStatusDraft
54 InvoiceStatusOpen = billingdb.BillingInvoiceStatusOpen
55 InvoiceStatusPaid = billingdb.BillingInvoiceStatusPaid
56 InvoiceStatusVoid = billingdb.BillingInvoiceStatusVoid
57 InvoiceStatusUncollectible = billingdb.BillingInvoiceStatusUncollectible
58 InvoiceStatusRefunded = billingdb.BillingInvoiceStatusRefunded
59 )
60
61 var (
62 ErrPoolRequired = errors.New("billing: pool is required")
63 ErrOrgIDRequired = errors.New("billing: org id is required")
64 ErrStripeCustomerID = errors.New("billing: stripe customer id is required")
65 ErrStripeSubscriptionID = errors.New("billing: stripe subscription id is required")
66 ErrStripeInvoiceID = errors.New("billing: stripe invoice id is required")
67 ErrInvalidPlan = errors.New("billing: invalid plan")
68 ErrInvalidStatus = errors.New("billing: invalid subscription status")
69 ErrInvalidInvoiceStatus = errors.New("billing: invalid invoice status")
70 ErrInvalidSeatCount = errors.New("billing: seat counts cannot be negative")
71 ErrWebhookEventID = errors.New("billing: webhook event id is required")
72 ErrWebhookEventType = errors.New("billing: webhook event type is required")
73 ErrWebhookPayload = errors.New("billing: webhook payload must be a JSON object")
74 )
75
76 // SubscriptionSnapshot is the local projection of a provider
77 // subscription event. Provider-specific conversion belongs in SP03.
78 type SubscriptionSnapshot struct {
79 OrgID int64
80 Plan Plan
81 Status SubscriptionStatus
82 StripeSubscriptionID string
83 StripeSubscriptionItemID string
84 CurrentPeriodStart time.Time
85 CurrentPeriodEnd time.Time
86 CancelAtPeriodEnd bool
87 TrialEnd time.Time
88 CanceledAt time.Time
89 LastWebhookEventID string
90 }
91
92 type SeatSnapshot struct {
93 OrgID int64
94 StripeSubscriptionID string
95 ActiveMembers int
96 BillableSeats int
97 Source string
98 }
99
100 type WebhookEvent struct {
101 ProviderEventID string
102 EventType string
103 APIVersion string
104 Payload []byte
105 }
106
107 type InvoiceSnapshot struct {
108 OrgID int64
109 StripeInvoiceID string
110 StripeCustomerID string
111 StripeSubscriptionID string
112 Status InvoiceStatus
113 Number string
114 Currency string
115 AmountDueCents int64
116 AmountPaidCents int64
117 AmountRemainingCents int64
118 HostedInvoiceURL string
119 InvoicePDFURL string
120 PeriodStart time.Time
121 PeriodEnd time.Time
122 DueAt time.Time
123 PaidAt time.Time
124 VoidedAt time.Time
125 }
126
127 func GetOrgBillingState(ctx context.Context, deps Deps, orgID int64) (State, error) {
128 if err := validateDeps(deps); err != nil {
129 return State{}, err
130 }
131 if orgID == 0 {
132 return State{}, ErrOrgIDRequired
133 }
134 return billingdb.New().GetOrgBillingState(ctx, deps.Pool, orgID)
135 }
136
137 // GetUserBillingState is the user-side counterpart to
138 // GetOrgBillingState. Returns pgx.ErrNoRows if the user has no
139 // seeded billing state (shouldn't happen post-PRO03 backfill but
140 // callers handle defensively).
141 func GetUserBillingState(ctx context.Context, deps Deps, userID int64) (UserState, error) {
142 if err := validateDeps(deps); err != nil {
143 return UserState{}, err
144 }
145 if userID == 0 {
146 return UserState{}, ErrOrgIDRequired // reuse: "subject id required"
147 }
148 return billingdb.New().GetUserBillingState(ctx, deps.Pool, userID)
149 }
150
151 func GetOrgBillingStateByStripeCustomer(ctx context.Context, deps Deps, customerID string) (State, error) {
152 if err := validateDeps(deps); err != nil {
153 return State{}, err
154 }
155 customerID = strings.TrimSpace(customerID)
156 if customerID == "" {
157 return State{}, ErrStripeCustomerID
158 }
159 return billingdb.New().GetOrgBillingStateByStripeCustomer(ctx, deps.Pool, pgText(customerID))
160 }
161
162 func GetOrgBillingStateByStripeSubscription(ctx context.Context, deps Deps, subscriptionID string) (State, error) {
163 if err := validateDeps(deps); err != nil {
164 return State{}, err
165 }
166 subscriptionID = strings.TrimSpace(subscriptionID)
167 if subscriptionID == "" {
168 return State{}, ErrStripeSubscriptionID
169 }
170 return billingdb.New().GetOrgBillingStateByStripeSubscription(ctx, deps.Pool, pgText(subscriptionID))
171 }
172
173 func SetStripeCustomer(ctx context.Context, deps Deps, orgID int64, customerID string) (State, error) {
174 if err := validateDeps(deps); err != nil {
175 return State{}, err
176 }
177 if orgID == 0 {
178 return State{}, ErrOrgIDRequired
179 }
180 customerID = strings.TrimSpace(customerID)
181 if customerID == "" {
182 return State{}, ErrStripeCustomerID
183 }
184 return billingdb.New().SetStripeCustomer(ctx, deps.Pool, billingdb.SetStripeCustomerParams{
185 OrgID: orgID,
186 StripeCustomerID: pgText(customerID),
187 })
188 }
189
190 func ApplySubscriptionSnapshot(ctx context.Context, deps Deps, snap SubscriptionSnapshot) (State, error) {
191 if err := validateDeps(deps); err != nil {
192 return State{}, err
193 }
194 if snap.OrgID == 0 {
195 return State{}, ErrOrgIDRequired
196 }
197 if !validPlan(snap.Plan) {
198 return State{}, fmt.Errorf("%w: %q", ErrInvalidPlan, snap.Plan)
199 }
200 if !validStatus(snap.Status) {
201 return State{}, fmt.Errorf("%w: %q", ErrInvalidStatus, snap.Status)
202 }
203 row, err := billingdb.New().ApplySubscriptionSnapshot(ctx, deps.Pool, billingdb.ApplySubscriptionSnapshotParams{
204 OrgID: snap.OrgID,
205 Plan: snap.Plan,
206 SubscriptionStatus: snap.Status,
207 StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
208 StripeSubscriptionItemID: pgText(snap.StripeSubscriptionItemID),
209 CurrentPeriodStart: pgTime(snap.CurrentPeriodStart),
210 CurrentPeriodEnd: pgTime(snap.CurrentPeriodEnd),
211 CancelAtPeriodEnd: snap.CancelAtPeriodEnd,
212 TrialEnd: pgTime(snap.TrialEnd),
213 CanceledAt: pgTime(snap.CanceledAt),
214 LastWebhookEventID: strings.TrimSpace(snap.LastWebhookEventID),
215 })
216 if err != nil {
217 return State{}, err
218 }
219 return stateFromApply(row), nil
220 }
221
222 func RecordWebhookEvent(ctx context.Context, deps Deps, event WebhookEvent) (billingdb.BillingWebhookEvent, bool, error) {
223 if err := validateDeps(deps); err != nil {
224 return billingdb.BillingWebhookEvent{}, false, err
225 }
226 event.ProviderEventID = strings.TrimSpace(event.ProviderEventID)
227 event.EventType = strings.TrimSpace(event.EventType)
228 event.APIVersion = strings.TrimSpace(event.APIVersion)
229 if event.ProviderEventID == "" {
230 return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventID
231 }
232 if event.EventType == "" {
233 return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventType
234 }
235 if !jsonObject(event.Payload) {
236 return billingdb.BillingWebhookEvent{}, false, ErrWebhookPayload
237 }
238 row, err := billingdb.New().CreateWebhookEventReceipt(ctx, deps.Pool, billingdb.CreateWebhookEventReceiptParams{
239 ProviderEventID: event.ProviderEventID,
240 EventType: event.EventType,
241 ApiVersion: event.APIVersion,
242 Payload: event.Payload,
243 })
244 if err != nil {
245 if errors.Is(err, pgx.ErrNoRows) {
246 row, err = billingdb.New().GetWebhookEventReceipt(ctx, deps.Pool, event.ProviderEventID)
247 if err != nil {
248 return billingdb.BillingWebhookEvent{}, false, err
249 }
250 return row, false, nil
251 }
252 return billingdb.BillingWebhookEvent{}, false, err
253 }
254 return row, true, nil
255 }
256
257 func MarkWebhookEventProcessed(ctx context.Context, deps Deps, providerEventID string) (billingdb.BillingWebhookEvent, error) {
258 if err := validateDeps(deps); err != nil {
259 return billingdb.BillingWebhookEvent{}, err
260 }
261 providerEventID = strings.TrimSpace(providerEventID)
262 if providerEventID == "" {
263 return billingdb.BillingWebhookEvent{}, ErrWebhookEventID
264 }
265 return billingdb.New().MarkWebhookEventProcessed(ctx, deps.Pool, providerEventID)
266 }
267
268 func MarkWebhookEventFailed(ctx context.Context, deps Deps, providerEventID, processError string) (billingdb.BillingWebhookEvent, error) {
269 if err := validateDeps(deps); err != nil {
270 return billingdb.BillingWebhookEvent{}, err
271 }
272 providerEventID = strings.TrimSpace(providerEventID)
273 if providerEventID == "" {
274 return billingdb.BillingWebhookEvent{}, ErrWebhookEventID
275 }
276 processError = strings.TrimSpace(processError)
277 if len(processError) > 2000 {
278 processError = processError[:2000]
279 }
280 return billingdb.New().MarkWebhookEventFailed(ctx, deps.Pool, billingdb.MarkWebhookEventFailedParams{
281 ProviderEventID: providerEventID,
282 ProcessError: processError,
283 })
284 }
285
286 func GetWebhookEventReceipt(ctx context.Context, deps Deps, providerEventID string) (billingdb.BillingWebhookEvent, error) {
287 if err := validateDeps(deps); err != nil {
288 return billingdb.BillingWebhookEvent{}, err
289 }
290 providerEventID = strings.TrimSpace(providerEventID)
291 if providerEventID == "" {
292 return billingdb.BillingWebhookEvent{}, ErrWebhookEventID
293 }
294 return billingdb.New().GetWebhookEventReceipt(ctx, deps.Pool, providerEventID)
295 }
296
297 // SetWebhookEventSubjectForPrincipal records the resolved subject on
298 // the receipt row. Called after a successful subject-resolution step
299 // in the webhook apply path (before guard + state mutation) so the
300 // audit trail survives even if the apply later fails. Migration 0075's
301 // CHECK constraint enforces both-or-neither — the helper rejects a
302 // zero principal.
303 func SetWebhookEventSubjectForPrincipal(ctx context.Context, deps Deps, providerEventID string, p Principal) error {
304 if err := validateDeps(deps); err != nil {
305 return err
306 }
307 providerEventID = strings.TrimSpace(providerEventID)
308 if providerEventID == "" {
309 return ErrWebhookEventID
310 }
311 if err := p.Validate(); err != nil {
312 return err
313 }
314 return billingdb.New().SetWebhookEventSubject(ctx, deps.Pool, billingdb.SetWebhookEventSubjectParams{
315 SubjectKind: billingdb.BillingSubjectKind(p.Kind),
316 SubjectID: p.ID,
317 ProviderEventID: providerEventID,
318 })
319 }
320
321 // MarkInvoiceRefunded flips a billing_invoices row to status='refunded'
322 // and stamps refunded_at. PRO08 D2: surface a Stripe-side refund in
323 // shithub's billing settings UI. The Stripe invoice itself stays
324 // status='paid' after a refund; shithub maintains its own UI surface.
325 // Returns pgx.ErrNoRows when the invoice id isn't on file (Stripe
326 // refunded an invoice we never recorded — operator should reconcile).
327 func MarkInvoiceRefunded(ctx context.Context, deps Deps, stripeInvoiceID string) (billingdb.BillingInvoice, error) {
328 if err := validateDeps(deps); err != nil {
329 return billingdb.BillingInvoice{}, err
330 }
331 stripeInvoiceID = strings.TrimSpace(stripeInvoiceID)
332 if stripeInvoiceID == "" {
333 return billingdb.BillingInvoice{}, ErrStripeInvoiceID
334 }
335 return billingdb.New().MarkInvoiceRefunded(ctx, deps.Pool, stripeInvoiceID)
336 }
337
338 // IsBillingEventStaleForPrincipal reports whether an incoming Stripe
339 // event's timestamp is older than the last event we've already applied
340 // for this principal. PRO08 D4: the handler refuses stale events so
341 // reverse-ordered retries can't regress state (e.g., a stale
342 // subscription.updated[active] arriving after a fresh
343 // subscription.updated[canceled] re-activating the principal).
344 //
345 // Returns false when there's no prior event on file (the first event
346 // is never stale) or when the row simply doesn't exist (defaults to
347 // allow; the caller's own ErrNoRows path handles missing-state).
348 func IsBillingEventStaleForPrincipal(ctx context.Context, deps Deps, p Principal, eventAt time.Time) (bool, error) {
349 if err := validateDeps(deps); err != nil {
350 return false, err
351 }
352 if err := p.Validate(); err != nil {
353 return false, err
354 }
355 if eventAt.IsZero() {
356 return false, nil
357 }
358 q := billingdb.New()
359 switch p.Kind {
360 case SubjectKindOrg:
361 stale, err := q.IsOrgBillingEventStale(ctx, deps.Pool, billingdb.IsOrgBillingEventStaleParams{
362 OrgID: p.ID,
363 EventAt: pgTime(eventAt),
364 })
365 if err != nil {
366 if errors.Is(err, pgx.ErrNoRows) {
367 return false, nil
368 }
369 return false, err
370 }
371 return stale, nil
372 case SubjectKindUser:
373 stale, err := q.IsUserBillingEventStale(ctx, deps.Pool, billingdb.IsUserBillingEventStaleParams{
374 UserID: p.ID,
375 EventAt: pgTime(eventAt),
376 })
377 if err != nil {
378 if errors.Is(err, pgx.ErrNoRows) {
379 return false, nil
380 }
381 return false, err
382 }
383 return stale, nil
384 }
385 return false, nil
386 }
387
388 // TouchBillingLastEventAtForPrincipal bumps last_event_at on the
389 // principal's billing-state row. PRO08 D4: called after a successful
390 // apply so subsequent staleness checks have a baseline. The query
391 // uses GREATEST(prev, incoming) so an out-of-order-but-recent retry
392 // doesn't regress the timestamp.
393 func TouchBillingLastEventAtForPrincipal(ctx context.Context, deps Deps, p Principal, eventAt time.Time) error {
394 if err := validateDeps(deps); err != nil {
395 return err
396 }
397 if err := p.Validate(); err != nil {
398 return err
399 }
400 if eventAt.IsZero() {
401 return nil
402 }
403 q := billingdb.New()
404 switch p.Kind {
405 case SubjectKindOrg:
406 return q.TouchOrgBillingLastEventAt(ctx, deps.Pool, billingdb.TouchOrgBillingLastEventAtParams{
407 OrgID: p.ID,
408 EventAt: pgTime(eventAt),
409 })
410 case SubjectKindUser:
411 return q.TouchUserBillingLastEventAt(ctx, deps.Pool, billingdb.TouchUserBillingLastEventAtParams{
412 UserID: p.ID,
413 EventAt: pgTime(eventAt),
414 })
415 }
416 return nil
417 }
418
419 // ListFailedWebhookEvents is the operator query for "events we
420 // received but failed to process." Returns rows whose process_error
421 // is non-empty OR that have any processing_attempts but no
422 // processed_at (in-flight failures). Returned in descending received_at
423 // order; limit caps the result set.
424 func ListFailedWebhookEvents(ctx context.Context, deps Deps, limit int32) ([]billingdb.ListFailedWebhookEventsRow, error) {
425 if err := validateDeps(deps); err != nil {
426 return nil, err
427 }
428 if limit <= 0 {
429 limit = 50
430 }
431 return billingdb.New().ListFailedWebhookEvents(ctx, deps.Pool, limit)
432 }
433
434 func UpsertInvoice(ctx context.Context, deps Deps, snap InvoiceSnapshot) (billingdb.BillingInvoice, error) {
435 if err := validateDeps(deps); err != nil {
436 return billingdb.BillingInvoice{}, err
437 }
438 if snap.OrgID == 0 {
439 return billingdb.BillingInvoice{}, ErrOrgIDRequired
440 }
441 snap.StripeInvoiceID = strings.TrimSpace(snap.StripeInvoiceID)
442 if snap.StripeInvoiceID == "" {
443 return billingdb.BillingInvoice{}, ErrStripeInvoiceID
444 }
445 snap.StripeCustomerID = strings.TrimSpace(snap.StripeCustomerID)
446 if snap.StripeCustomerID == "" {
447 return billingdb.BillingInvoice{}, ErrStripeCustomerID
448 }
449 if !validInvoiceStatus(snap.Status) {
450 return billingdb.BillingInvoice{}, fmt.Errorf("%w: %q", ErrInvalidInvoiceStatus, snap.Status)
451 }
452 row, err := billingdb.New().UpsertInvoice(ctx, deps.Pool, billingdb.UpsertInvoiceParams{
453 OrgID: snap.OrgID,
454 StripeInvoiceID: snap.StripeInvoiceID,
455 StripeCustomerID: snap.StripeCustomerID,
456 StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
457 Status: snap.Status,
458 Number: strings.TrimSpace(snap.Number),
459 Currency: strings.ToLower(strings.TrimSpace(snap.Currency)),
460 AmountDueCents: snap.AmountDueCents,
461 AmountPaidCents: snap.AmountPaidCents,
462 AmountRemainingCents: snap.AmountRemainingCents,
463 HostedInvoiceUrl: strings.TrimSpace(snap.HostedInvoiceURL),
464 InvoicePdfUrl: strings.TrimSpace(snap.InvoicePDFURL),
465 PeriodStart: pgTime(snap.PeriodStart),
466 PeriodEnd: pgTime(snap.PeriodEnd),
467 DueAt: pgTime(snap.DueAt),
468 PaidAt: pgTime(snap.PaidAt),
469 VoidedAt: pgTime(snap.VoidedAt),
470 })
471 if err != nil {
472 return billingdb.BillingInvoice{}, err
473 }
474 return row, nil
475 }
476
477 func ListInvoicesForOrg(ctx context.Context, deps Deps, orgID int64, limit int32) ([]billingdb.BillingInvoice, error) {
478 if err := validateDeps(deps); err != nil {
479 return nil, err
480 }
481 if orgID == 0 {
482 return nil, ErrOrgIDRequired
483 }
484 if limit <= 0 {
485 limit = 10
486 }
487 return billingdb.New().ListInvoicesForOrg(ctx, deps.Pool, billingdb.ListInvoicesForOrgParams{
488 // SubjectID equals OrgID by the billing_invoices_org_id_matches_subject
489 // CHECK constraint added in migration 0074. The polymorphic shape lets
490 // PRO04+ callers reuse this query without a fork.
491 SubjectID: orgID,
492 Limit: limit,
493 })
494 }
495
496 func SyncSeatSnapshot(ctx context.Context, deps Deps, snap SeatSnapshot) (billingdb.BillingSeatSnapshot, error) {
497 if err := validateDeps(deps); err != nil {
498 return billingdb.BillingSeatSnapshot{}, err
499 }
500 if snap.OrgID == 0 {
501 return billingdb.BillingSeatSnapshot{}, ErrOrgIDRequired
502 }
503 if snap.ActiveMembers < 0 || snap.BillableSeats < 0 {
504 return billingdb.BillingSeatSnapshot{}, ErrInvalidSeatCount
505 }
506 source := strings.TrimSpace(snap.Source)
507 if source == "" {
508 source = "local"
509 }
510 row, err := billingdb.New().CreateSeatSnapshot(ctx, deps.Pool, billingdb.CreateSeatSnapshotParams{
511 OrgID: snap.OrgID,
512 StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
513 ActiveMembers: int32(snap.ActiveMembers),
514 BillableSeats: int32(snap.BillableSeats),
515 Source: source,
516 })
517 if err != nil {
518 return billingdb.BillingSeatSnapshot{}, err
519 }
520 return billingdb.BillingSeatSnapshot(row), nil
521 }
522
523 func CountBillableOrgMembers(ctx context.Context, deps Deps, orgID int64) (int, error) {
524 if err := validateDeps(deps); err != nil {
525 return 0, err
526 }
527 if orgID == 0 {
528 return 0, ErrOrgIDRequired
529 }
530 n, err := billingdb.New().CountBillableOrgMembers(ctx, deps.Pool, orgID)
531 if err != nil {
532 return 0, err
533 }
534 return int(n), nil
535 }
536
537 func CountPendingOrgInvitations(ctx context.Context, deps Deps, orgID int64) (int, error) {
538 if err := validateDeps(deps); err != nil {
539 return 0, err
540 }
541 if orgID == 0 {
542 return 0, ErrOrgIDRequired
543 }
544 n, err := billingdb.New().CountPendingOrgInvitations(ctx, deps.Pool, orgID)
545 if err != nil {
546 return 0, err
547 }
548 return int(n), nil
549 }
550
551 func MarkPastDue(ctx context.Context, deps Deps, orgID int64, graceUntil time.Time, lastWebhookEventID string) (State, error) {
552 if err := validateDeps(deps); err != nil {
553 return State{}, err
554 }
555 if orgID == 0 {
556 return State{}, ErrOrgIDRequired
557 }
558 return billingdb.New().MarkPastDue(ctx, deps.Pool, billingdb.MarkPastDueParams{
559 OrgID: orgID,
560 GraceUntil: pgTime(graceUntil),
561 LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
562 })
563 }
564
565 func MarkPaymentSucceeded(ctx context.Context, deps Deps, orgID int64, lastWebhookEventID string) (State, error) {
566 if err := validateDeps(deps); err != nil {
567 return State{}, err
568 }
569 if orgID == 0 {
570 return State{}, ErrOrgIDRequired
571 }
572 row, err := billingdb.New().MarkPaymentSucceeded(ctx, deps.Pool, billingdb.MarkPaymentSucceededParams{
573 OrgID: orgID,
574 LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
575 })
576 if err != nil {
577 return State{}, err
578 }
579 return stateFromPaymentSucceeded(row), nil
580 }
581
582 func MarkCanceled(ctx context.Context, deps Deps, orgID int64, lastWebhookEventID string) (State, error) {
583 if err := validateDeps(deps); err != nil {
584 return State{}, err
585 }
586 if orgID == 0 {
587 return State{}, ErrOrgIDRequired
588 }
589 row, err := billingdb.New().MarkCanceled(ctx, deps.Pool, billingdb.MarkCanceledParams{
590 OrgID: orgID,
591 LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
592 })
593 if err != nil {
594 return State{}, err
595 }
596 return stateFromCanceled(row), nil
597 }
598
599 func ClearBillingLock(ctx context.Context, deps Deps, orgID int64) (State, error) {
600 if err := validateDeps(deps); err != nil {
601 return State{}, err
602 }
603 if orgID == 0 {
604 return State{}, ErrOrgIDRequired
605 }
606 row, err := billingdb.New().ClearBillingLock(ctx, deps.Pool, orgID)
607 if err != nil {
608 return State{}, err
609 }
610 return stateFromClear(row), nil
611 }
612
613 func validateDeps(deps Deps) error {
614 if deps.Pool == nil {
615 return ErrPoolRequired
616 }
617 return nil
618 }
619
620 func validPlan(plan Plan) bool {
621 switch plan {
622 case PlanFree, PlanTeam, PlanEnterprise:
623 return true
624 default:
625 return false
626 }
627 }
628
629 func validStatus(status SubscriptionStatus) bool {
630 switch status {
631 case SubscriptionStatusNone,
632 SubscriptionStatusIncomplete,
633 SubscriptionStatusTrialing,
634 SubscriptionStatusActive,
635 SubscriptionStatusPastDue,
636 SubscriptionStatusCanceled,
637 SubscriptionStatusUnpaid,
638 SubscriptionStatusPaused:
639 return true
640 default:
641 return false
642 }
643 }
644
645 func validInvoiceStatus(status InvoiceStatus) bool {
646 switch status {
647 case InvoiceStatusDraft,
648 InvoiceStatusOpen,
649 InvoiceStatusPaid,
650 InvoiceStatusVoid,
651 InvoiceStatusUncollectible,
652 InvoiceStatusRefunded:
653 return true
654 default:
655 return false
656 }
657 }
658
659 func pgText(s string) pgtype.Text {
660 s = strings.TrimSpace(s)
661 return pgtype.Text{String: s, Valid: s != ""}
662 }
663
664 func pgTime(t time.Time) pgtype.Timestamptz {
665 return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()}
666 }
667
668 func jsonObject(payload []byte) bool {
669 var v map[string]any
670 return json.Unmarshal(payload, &v) == nil && v != nil
671 }
672
673 func stateFromApply(row billingdb.ApplySubscriptionSnapshotRow) State {
674 return State(row)
675 }
676
677 func stateFromCanceled(row billingdb.MarkCanceledRow) State {
678 return State(row)
679 }
680
681 func stateFromPaymentSucceeded(row billingdb.MarkPaymentSucceededRow) State {
682 return State(row)
683 }
684
685 func stateFromClear(row billingdb.ClearBillingLockRow) State {
686 return State(row)
687 }
688