tenseleyflow/shithub / eaedf22

Browse files

Add billing state service

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
eaedf22b83ddb0a1b99b2a4323745857524cee8c
Parents
2d00a68
Tree
16ded76

3 changed files

StatusFile+-
M docs/internal/billing.md 15 0
A internal/billing/billing.go 373 0
A internal/billing/billing_test.go 208 0
docs/internal/billing.mdmodified
@@ -131,6 +131,21 @@ Required local concepts:
131131
 - Seat snapshots for auditability.
132132
 - Billing grace/lock state derived from processed subscription events.
133133
 
134
+PAYMENTS SP02 adds these as local database tables:
135
+
136
+- `org_billing_states` stores the organization billing projection used
137
+  by entitlement checks.
138
+- `billing_seat_snapshots` records active and billable seat counts over
139
+  time.
140
+- `billing_invoices` stores invoice/payment summaries for billing UI.
141
+- `billing_webhook_events` stores immutable provider event receipts for
142
+  idempotent webhook processing.
143
+
144
+New organizations receive a Free billing state from a database trigger,
145
+and the migration backfills existing organizations as Free. Subscription
146
+snapshot writes also keep `orgs.plan` synchronized as the
147
+human-facing summary.
148
+
134149
 ## Entitlement architecture
135150
 
136151
 Paid feature checks must live behind a central entitlement package, not
internal/billing/billing.goadded
@@ -0,0 +1,373 @@
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; webhook/API integration lands in SP03.
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 Plan = billingdb.OrgPlan
28
+type SubscriptionStatus = billingdb.BillingSubscriptionStatus
29
+type State = billingdb.OrgBillingState
30
+
31
+const (
32
+	PlanFree       = billingdb.OrgPlanFree
33
+	PlanTeam       = billingdb.OrgPlanTeam
34
+	PlanEnterprise = billingdb.OrgPlanEnterprise
35
+
36
+	SubscriptionStatusNone       = billingdb.BillingSubscriptionStatusNone
37
+	SubscriptionStatusIncomplete = billingdb.BillingSubscriptionStatusIncomplete
38
+	SubscriptionStatusTrialing   = billingdb.BillingSubscriptionStatusTrialing
39
+	SubscriptionStatusActive     = billingdb.BillingSubscriptionStatusActive
40
+	SubscriptionStatusPastDue    = billingdb.BillingSubscriptionStatusPastDue
41
+	SubscriptionStatusCanceled   = billingdb.BillingSubscriptionStatusCanceled
42
+	SubscriptionStatusUnpaid     = billingdb.BillingSubscriptionStatusUnpaid
43
+	SubscriptionStatusPaused     = billingdb.BillingSubscriptionStatusPaused
44
+)
45
+
46
+var (
47
+	ErrPoolRequired     = errors.New("billing: pool is required")
48
+	ErrOrgIDRequired    = errors.New("billing: org id is required")
49
+	ErrStripeCustomerID = errors.New("billing: stripe customer id is required")
50
+	ErrInvalidPlan      = errors.New("billing: invalid plan")
51
+	ErrInvalidStatus    = errors.New("billing: invalid subscription status")
52
+	ErrInvalidSeatCount = errors.New("billing: seat counts cannot be negative")
53
+	ErrWebhookEventID   = errors.New("billing: webhook event id is required")
54
+	ErrWebhookEventType = errors.New("billing: webhook event type is required")
55
+	ErrWebhookPayload   = errors.New("billing: webhook payload must be a JSON object")
56
+)
57
+
58
+// SubscriptionSnapshot is the local projection of a provider
59
+// subscription event. Provider-specific conversion belongs in SP03.
60
+type SubscriptionSnapshot struct {
61
+	OrgID                    int64
62
+	Plan                     Plan
63
+	Status                   SubscriptionStatus
64
+	StripeSubscriptionID     string
65
+	StripeSubscriptionItemID string
66
+	CurrentPeriodStart       time.Time
67
+	CurrentPeriodEnd         time.Time
68
+	CancelAtPeriodEnd        bool
69
+	TrialEnd                 time.Time
70
+	CanceledAt               time.Time
71
+	LastWebhookEventID       string
72
+}
73
+
74
+type SeatSnapshot struct {
75
+	OrgID                int64
76
+	StripeSubscriptionID string
77
+	ActiveMembers        int
78
+	BillableSeats        int
79
+	Source               string
80
+}
81
+
82
+type WebhookEvent struct {
83
+	ProviderEventID string
84
+	EventType       string
85
+	APIVersion      string
86
+	Payload         []byte
87
+}
88
+
89
+func GetOrgBillingState(ctx context.Context, deps Deps, orgID int64) (State, error) {
90
+	if err := validateDeps(deps); err != nil {
91
+		return State{}, err
92
+	}
93
+	if orgID == 0 {
94
+		return State{}, ErrOrgIDRequired
95
+	}
96
+	return billingdb.New().GetOrgBillingState(ctx, deps.Pool, orgID)
97
+}
98
+
99
+func SetStripeCustomer(ctx context.Context, deps Deps, orgID int64, customerID string) (State, error) {
100
+	if err := validateDeps(deps); err != nil {
101
+		return State{}, err
102
+	}
103
+	if orgID == 0 {
104
+		return State{}, ErrOrgIDRequired
105
+	}
106
+	customerID = strings.TrimSpace(customerID)
107
+	if customerID == "" {
108
+		return State{}, ErrStripeCustomerID
109
+	}
110
+	return billingdb.New().SetStripeCustomer(ctx, deps.Pool, billingdb.SetStripeCustomerParams{
111
+		OrgID:            orgID,
112
+		StripeCustomerID: pgText(customerID),
113
+	})
114
+}
115
+
116
+func ApplySubscriptionSnapshot(ctx context.Context, deps Deps, snap SubscriptionSnapshot) (State, error) {
117
+	if err := validateDeps(deps); err != nil {
118
+		return State{}, err
119
+	}
120
+	if snap.OrgID == 0 {
121
+		return State{}, ErrOrgIDRequired
122
+	}
123
+	if !validPlan(snap.Plan) {
124
+		return State{}, fmt.Errorf("%w: %q", ErrInvalidPlan, snap.Plan)
125
+	}
126
+	if !validStatus(snap.Status) {
127
+		return State{}, fmt.Errorf("%w: %q", ErrInvalidStatus, snap.Status)
128
+	}
129
+	row, err := billingdb.New().ApplySubscriptionSnapshot(ctx, deps.Pool, billingdb.ApplySubscriptionSnapshotParams{
130
+		OrgID:                    snap.OrgID,
131
+		Plan:                     snap.Plan,
132
+		SubscriptionStatus:       snap.Status,
133
+		StripeSubscriptionID:     pgText(snap.StripeSubscriptionID),
134
+		StripeSubscriptionItemID: pgText(snap.StripeSubscriptionItemID),
135
+		CurrentPeriodStart:       pgTime(snap.CurrentPeriodStart),
136
+		CurrentPeriodEnd:         pgTime(snap.CurrentPeriodEnd),
137
+		CancelAtPeriodEnd:        snap.CancelAtPeriodEnd,
138
+		TrialEnd:                 pgTime(snap.TrialEnd),
139
+		CanceledAt:               pgTime(snap.CanceledAt),
140
+		LastWebhookEventID:       strings.TrimSpace(snap.LastWebhookEventID),
141
+	})
142
+	if err != nil {
143
+		return State{}, err
144
+	}
145
+	return stateFromApply(row), nil
146
+}
147
+
148
+func RecordWebhookEvent(ctx context.Context, deps Deps, event WebhookEvent) (billingdb.BillingWebhookEvent, bool, error) {
149
+	if err := validateDeps(deps); err != nil {
150
+		return billingdb.BillingWebhookEvent{}, false, err
151
+	}
152
+	event.ProviderEventID = strings.TrimSpace(event.ProviderEventID)
153
+	event.EventType = strings.TrimSpace(event.EventType)
154
+	event.APIVersion = strings.TrimSpace(event.APIVersion)
155
+	if event.ProviderEventID == "" {
156
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventID
157
+	}
158
+	if event.EventType == "" {
159
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookEventType
160
+	}
161
+	if !jsonObject(event.Payload) {
162
+		return billingdb.BillingWebhookEvent{}, false, ErrWebhookPayload
163
+	}
164
+	row, err := billingdb.New().CreateWebhookEventReceipt(ctx, deps.Pool, billingdb.CreateWebhookEventReceiptParams{
165
+		ProviderEventID: event.ProviderEventID,
166
+		EventType:       event.EventType,
167
+		ApiVersion:      event.APIVersion,
168
+		Payload:         event.Payload,
169
+	})
170
+	if err != nil {
171
+		if errors.Is(err, pgx.ErrNoRows) {
172
+			return billingdb.BillingWebhookEvent{}, false, nil
173
+		}
174
+		return billingdb.BillingWebhookEvent{}, false, err
175
+	}
176
+	return row, true, nil
177
+}
178
+
179
+func SyncSeatSnapshot(ctx context.Context, deps Deps, snap SeatSnapshot) (billingdb.BillingSeatSnapshot, error) {
180
+	if err := validateDeps(deps); err != nil {
181
+		return billingdb.BillingSeatSnapshot{}, err
182
+	}
183
+	if snap.OrgID == 0 {
184
+		return billingdb.BillingSeatSnapshot{}, ErrOrgIDRequired
185
+	}
186
+	if snap.ActiveMembers < 0 || snap.BillableSeats < 0 {
187
+		return billingdb.BillingSeatSnapshot{}, ErrInvalidSeatCount
188
+	}
189
+	source := strings.TrimSpace(snap.Source)
190
+	if source == "" {
191
+		source = "local"
192
+	}
193
+	row, err := billingdb.New().CreateSeatSnapshot(ctx, deps.Pool, billingdb.CreateSeatSnapshotParams{
194
+		OrgID:                snap.OrgID,
195
+		StripeSubscriptionID: pgText(snap.StripeSubscriptionID),
196
+		ActiveMembers:        int32(snap.ActiveMembers),
197
+		BillableSeats:        int32(snap.BillableSeats),
198
+		Source:               source,
199
+	})
200
+	if err != nil {
201
+		return billingdb.BillingSeatSnapshot{}, err
202
+	}
203
+	return billingdb.BillingSeatSnapshot(row), nil
204
+}
205
+
206
+func MarkPastDue(ctx context.Context, deps Deps, orgID int64, graceUntil time.Time, lastWebhookEventID string) (State, error) {
207
+	if err := validateDeps(deps); err != nil {
208
+		return State{}, err
209
+	}
210
+	if orgID == 0 {
211
+		return State{}, ErrOrgIDRequired
212
+	}
213
+	return billingdb.New().MarkPastDue(ctx, deps.Pool, billingdb.MarkPastDueParams{
214
+		OrgID:              orgID,
215
+		GraceUntil:         pgTime(graceUntil),
216
+		LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
217
+	})
218
+}
219
+
220
+func MarkCanceled(ctx context.Context, deps Deps, orgID int64, lastWebhookEventID string) (State, error) {
221
+	if err := validateDeps(deps); err != nil {
222
+		return State{}, err
223
+	}
224
+	if orgID == 0 {
225
+		return State{}, ErrOrgIDRequired
226
+	}
227
+	row, err := billingdb.New().MarkCanceled(ctx, deps.Pool, billingdb.MarkCanceledParams{
228
+		OrgID:              orgID,
229
+		LastWebhookEventID: strings.TrimSpace(lastWebhookEventID),
230
+	})
231
+	if err != nil {
232
+		return State{}, err
233
+	}
234
+	return stateFromCanceled(row), nil
235
+}
236
+
237
+func ClearBillingLock(ctx context.Context, deps Deps, orgID int64) (State, error) {
238
+	if err := validateDeps(deps); err != nil {
239
+		return State{}, err
240
+	}
241
+	if orgID == 0 {
242
+		return State{}, ErrOrgIDRequired
243
+	}
244
+	row, err := billingdb.New().ClearBillingLock(ctx, deps.Pool, orgID)
245
+	if err != nil {
246
+		return State{}, err
247
+	}
248
+	return stateFromClear(row), nil
249
+}
250
+
251
+func validateDeps(deps Deps) error {
252
+	if deps.Pool == nil {
253
+		return ErrPoolRequired
254
+	}
255
+	return nil
256
+}
257
+
258
+func validPlan(plan Plan) bool {
259
+	switch plan {
260
+	case PlanFree, PlanTeam, PlanEnterprise:
261
+		return true
262
+	default:
263
+		return false
264
+	}
265
+}
266
+
267
+func validStatus(status SubscriptionStatus) bool {
268
+	switch status {
269
+	case SubscriptionStatusNone,
270
+		SubscriptionStatusIncomplete,
271
+		SubscriptionStatusTrialing,
272
+		SubscriptionStatusActive,
273
+		SubscriptionStatusPastDue,
274
+		SubscriptionStatusCanceled,
275
+		SubscriptionStatusUnpaid,
276
+		SubscriptionStatusPaused:
277
+		return true
278
+	default:
279
+		return false
280
+	}
281
+}
282
+
283
+func pgText(s string) pgtype.Text {
284
+	s = strings.TrimSpace(s)
285
+	return pgtype.Text{String: s, Valid: s != ""}
286
+}
287
+
288
+func pgTime(t time.Time) pgtype.Timestamptz {
289
+	return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()}
290
+}
291
+
292
+func jsonObject(payload []byte) bool {
293
+	var v map[string]any
294
+	return json.Unmarshal(payload, &v) == nil && v != nil
295
+}
296
+
297
+func stateFromApply(row billingdb.ApplySubscriptionSnapshotRow) State {
298
+	return State{
299
+		OrgID:                    row.OrgID,
300
+		Provider:                 row.Provider,
301
+		StripeCustomerID:         row.StripeCustomerID,
302
+		StripeSubscriptionID:     row.StripeSubscriptionID,
303
+		StripeSubscriptionItemID: row.StripeSubscriptionItemID,
304
+		Plan:                     row.Plan,
305
+		SubscriptionStatus:       row.SubscriptionStatus,
306
+		BillableSeats:            row.BillableSeats,
307
+		SeatSnapshotAt:           row.SeatSnapshotAt,
308
+		CurrentPeriodStart:       row.CurrentPeriodStart,
309
+		CurrentPeriodEnd:         row.CurrentPeriodEnd,
310
+		CancelAtPeriodEnd:        row.CancelAtPeriodEnd,
311
+		TrialEnd:                 row.TrialEnd,
312
+		PastDueAt:                row.PastDueAt,
313
+		CanceledAt:               row.CanceledAt,
314
+		LockedAt:                 row.LockedAt,
315
+		LockReason:               row.LockReason,
316
+		GraceUntil:               row.GraceUntil,
317
+		LastWebhookEventID:       row.LastWebhookEventID,
318
+		CreatedAt:                row.CreatedAt,
319
+		UpdatedAt:                row.UpdatedAt,
320
+	}
321
+}
322
+
323
+func stateFromCanceled(row billingdb.MarkCanceledRow) State {
324
+	return State{
325
+		OrgID:                    row.OrgID,
326
+		Provider:                 row.Provider,
327
+		StripeCustomerID:         row.StripeCustomerID,
328
+		StripeSubscriptionID:     row.StripeSubscriptionID,
329
+		StripeSubscriptionItemID: row.StripeSubscriptionItemID,
330
+		Plan:                     row.Plan,
331
+		SubscriptionStatus:       row.SubscriptionStatus,
332
+		BillableSeats:            row.BillableSeats,
333
+		SeatSnapshotAt:           row.SeatSnapshotAt,
334
+		CurrentPeriodStart:       row.CurrentPeriodStart,
335
+		CurrentPeriodEnd:         row.CurrentPeriodEnd,
336
+		CancelAtPeriodEnd:        row.CancelAtPeriodEnd,
337
+		TrialEnd:                 row.TrialEnd,
338
+		PastDueAt:                row.PastDueAt,
339
+		CanceledAt:               row.CanceledAt,
340
+		LockedAt:                 row.LockedAt,
341
+		LockReason:               row.LockReason,
342
+		GraceUntil:               row.GraceUntil,
343
+		LastWebhookEventID:       row.LastWebhookEventID,
344
+		CreatedAt:                row.CreatedAt,
345
+		UpdatedAt:                row.UpdatedAt,
346
+	}
347
+}
348
+
349
+func stateFromClear(row billingdb.ClearBillingLockRow) State {
350
+	return State{
351
+		OrgID:                    row.OrgID,
352
+		Provider:                 row.Provider,
353
+		StripeCustomerID:         row.StripeCustomerID,
354
+		StripeSubscriptionID:     row.StripeSubscriptionID,
355
+		StripeSubscriptionItemID: row.StripeSubscriptionItemID,
356
+		Plan:                     row.Plan,
357
+		SubscriptionStatus:       row.SubscriptionStatus,
358
+		BillableSeats:            row.BillableSeats,
359
+		SeatSnapshotAt:           row.SeatSnapshotAt,
360
+		CurrentPeriodStart:       row.CurrentPeriodStart,
361
+		CurrentPeriodEnd:         row.CurrentPeriodEnd,
362
+		CancelAtPeriodEnd:        row.CancelAtPeriodEnd,
363
+		TrialEnd:                 row.TrialEnd,
364
+		PastDueAt:                row.PastDueAt,
365
+		CanceledAt:               row.CanceledAt,
366
+		LockedAt:                 row.LockedAt,
367
+		LockReason:               row.LockReason,
368
+		GraceUntil:               row.GraceUntil,
369
+		LastWebhookEventID:       row.LastWebhookEventID,
370
+		CreatedAt:                row.CreatedAt,
371
+		UpdatedAt:                row.UpdatedAt,
372
+	}
373
+}
internal/billing/billing_test.goadded
@@ -0,0 +1,208 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package billing_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/billing"
15
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/orgs"
17
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
19
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20
+)
21
+
22
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
23
+	"AAAAAAAAAAAAAAAA$" +
24
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
25
+
26
+func setup(t *testing.T) (*pgxpool.Pool, billing.Deps, orgsdb.Org) {
27
+	t.Helper()
28
+	pool := dbtest.NewTestDB(t)
29
+	ctx := context.Background()
30
+	u, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
31
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
32
+	})
33
+	if err != nil {
34
+		t.Fatalf("create user: %v", err)
35
+	}
36
+	odeps := orgs.Deps{
37
+		Pool:   pool,
38
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
39
+	}
40
+	org, err := orgs.Create(ctx, odeps, orgs.CreateParams{
41
+		Slug: "acme", DisplayName: "Acme Inc", CreatedByUserID: u.ID,
42
+	})
43
+	if err != nil {
44
+		t.Fatalf("create org: %v", err)
45
+	}
46
+	return pool, billing.Deps{Pool: pool}, org
47
+}
48
+
49
+func TestBillingStateTransitions(t *testing.T) {
50
+	pool, deps, org := setup(t)
51
+	ctx := context.Background()
52
+
53
+	state, err := billing.GetOrgBillingState(ctx, deps, org.ID)
54
+	if err != nil {
55
+		t.Fatalf("GetOrgBillingState: %v", err)
56
+	}
57
+	if state.Plan != billing.PlanFree || state.SubscriptionStatus != billing.SubscriptionStatusNone {
58
+		t.Fatalf("new org state: plan=%s status=%s", state.Plan, state.SubscriptionStatus)
59
+	}
60
+
61
+	state, err = billing.SetStripeCustomer(ctx, deps, org.ID, "cus_test")
62
+	if err != nil {
63
+		t.Fatalf("SetStripeCustomer: %v", err)
64
+	}
65
+	if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test" {
66
+		t.Fatalf("stripe customer not set: %+v", state.StripeCustomerID)
67
+	}
68
+
69
+	start := time.Now().UTC().Truncate(time.Second)
70
+	state, err = billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
71
+		OrgID:                    org.ID,
72
+		Plan:                     billing.PlanTeam,
73
+		Status:                   billing.SubscriptionStatusActive,
74
+		StripeSubscriptionID:     "sub_test",
75
+		StripeSubscriptionItemID: "si_test",
76
+		CurrentPeriodStart:       start,
77
+		CurrentPeriodEnd:         start.Add(30 * 24 * time.Hour),
78
+		LastWebhookEventID:       "evt_active",
79
+	})
80
+	if err != nil {
81
+		t.Fatalf("ApplySubscriptionSnapshot active: %v", err)
82
+	}
83
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusActive)
84
+	if state.LockedAt.Valid || state.LockReason.Valid {
85
+		t.Fatalf("active subscription should not be locked: %+v", state)
86
+	}
87
+	assertOrgPlan(t, pool, org.ID, orgsdb.OrgPlanTeam)
88
+
89
+	grace := start.Add(7 * 24 * time.Hour)
90
+	state, err = billing.MarkPastDue(ctx, deps, org.ID, grace, "evt_past_due")
91
+	if err != nil {
92
+		t.Fatalf("MarkPastDue: %v", err)
93
+	}
94
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusPastDue)
95
+	if !state.LockedAt.Valid || !state.LockReason.Valid || state.LockReason.BillingLockReason != billingdb.BillingLockReasonPastDue {
96
+		t.Fatalf("past_due should set lock fields: %+v", state)
97
+	}
98
+	if !state.GraceUntil.Valid {
99
+		t.Fatalf("past_due should set grace_until")
100
+	}
101
+
102
+	state, err = billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
103
+		OrgID:                    org.ID,
104
+		Plan:                     billing.PlanTeam,
105
+		Status:                   billing.SubscriptionStatusActive,
106
+		StripeSubscriptionID:     "sub_test",
107
+		StripeSubscriptionItemID: "si_test",
108
+		CurrentPeriodStart:       start,
109
+		CurrentPeriodEnd:         start.Add(30 * 24 * time.Hour),
110
+		LastWebhookEventID:       "evt_recovered",
111
+	})
112
+	if err != nil {
113
+		t.Fatalf("ApplySubscriptionSnapshot recovered: %v", err)
114
+	}
115
+	assertState(t, state, billing.PlanTeam, billing.SubscriptionStatusActive)
116
+	if state.LockedAt.Valid || state.LockReason.Valid || state.GraceUntil.Valid || state.PastDueAt.Valid {
117
+		t.Fatalf("recovered subscription should clear lock/grace/past_due: %+v", state)
118
+	}
119
+
120
+	state, err = billing.MarkCanceled(ctx, deps, org.ID, "evt_canceled")
121
+	if err != nil {
122
+		t.Fatalf("MarkCanceled: %v", err)
123
+	}
124
+	assertState(t, state, billing.PlanFree, billing.SubscriptionStatusCanceled)
125
+	if !state.LockedAt.Valid || !state.LockReason.Valid || state.LockReason.BillingLockReason != billingdb.BillingLockReasonCanceled {
126
+		t.Fatalf("canceled subscription should set canceled lock: %+v", state)
127
+	}
128
+	assertOrgPlan(t, pool, org.ID, orgsdb.OrgPlanFree)
129
+
130
+	state, err = billing.ClearBillingLock(ctx, deps, org.ID)
131
+	if err != nil {
132
+		t.Fatalf("ClearBillingLock: %v", err)
133
+	}
134
+	assertState(t, state, billing.PlanFree, billing.SubscriptionStatusNone)
135
+	if state.LockedAt.Valid || state.LockReason.Valid || state.GraceUntil.Valid {
136
+		t.Fatalf("free state should clear billing lock: %+v", state)
137
+	}
138
+}
139
+
140
+func TestRecordWebhookEventIsIdempotent(t *testing.T) {
141
+	_, deps, _ := setup(t)
142
+	ctx := context.Background()
143
+
144
+	event := billing.WebhookEvent{
145
+		ProviderEventID: "evt_test",
146
+		EventType:       "customer.subscription.updated",
147
+		APIVersion:      "2024-06-20",
148
+		Payload:         []byte(`{"id":"evt_test"}`),
149
+	}
150
+	row, created, err := billing.RecordWebhookEvent(ctx, deps, event)
151
+	if err != nil {
152
+		t.Fatalf("RecordWebhookEvent first: %v", err)
153
+	}
154
+	if !created || row.ProviderEventID != "evt_test" {
155
+		t.Fatalf("first receipt created=%v row=%+v", created, row)
156
+	}
157
+
158
+	_, created, err = billing.RecordWebhookEvent(ctx, deps, event)
159
+	if err != nil {
160
+		t.Fatalf("RecordWebhookEvent duplicate: %v", err)
161
+	}
162
+	if created {
163
+		t.Fatalf("duplicate receipt should not be created")
164
+	}
165
+}
166
+
167
+func TestSyncSeatSnapshotUpdatesBillingState(t *testing.T) {
168
+	_, deps, org := setup(t)
169
+	ctx := context.Background()
170
+
171
+	snap, err := billing.SyncSeatSnapshot(ctx, deps, billing.SeatSnapshot{
172
+		OrgID:                org.ID,
173
+		StripeSubscriptionID: "sub_test",
174
+		ActiveMembers:        2,
175
+		BillableSeats:        2,
176
+	})
177
+	if err != nil {
178
+		t.Fatalf("SyncSeatSnapshot: %v", err)
179
+	}
180
+	if snap.ActiveMembers != 2 || snap.BillableSeats != 2 || snap.Source != "local" {
181
+		t.Fatalf("unexpected snapshot: %+v", snap)
182
+	}
183
+	state, err := billing.GetOrgBillingState(ctx, deps, org.ID)
184
+	if err != nil {
185
+		t.Fatalf("GetOrgBillingState: %v", err)
186
+	}
187
+	if state.BillableSeats != 2 || !state.SeatSnapshotAt.Valid {
188
+		t.Fatalf("state did not record seat snapshot: %+v", state)
189
+	}
190
+}
191
+
192
+func assertState(t *testing.T, state billing.State, plan billing.Plan, status billing.SubscriptionStatus) {
193
+	t.Helper()
194
+	if state.Plan != plan || state.SubscriptionStatus != status {
195
+		t.Fatalf("state: want plan=%s status=%s, got plan=%s status=%s", plan, status, state.Plan, state.SubscriptionStatus)
196
+	}
197
+}
198
+
199
+func assertOrgPlan(t *testing.T, pool *pgxpool.Pool, orgID int64, want orgsdb.OrgPlan) {
200
+	t.Helper()
201
+	row, err := orgsdb.New().GetOrgByID(context.Background(), pool, orgID)
202
+	if err != nil {
203
+		t.Fatalf("GetOrgByID: %v", err)
204
+	}
205
+	if row.Plan != want {
206
+		t.Fatalf("org plan: want %s, got %s", want, row.Plan)
207
+	}
208
+}