tenseleyflow/shithub / 8f753ab

Browse files

Add Stripe billing configuration

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8f753ab031fbdb53505a1a41d50a9110e16c3690
Parents
9d05372
Tree
df4e048

7 changed files

StatusFile+-
M .env.example 16 0
M docs/internal/billing.md 13 0
M docs/internal/config.md 15 0
M go.mod 1 0
M go.sum 2 0
M internal/infra/config/config.go 74 0
M internal/infra/config/config_test.go 70 1
.env.examplemodified
@@ -53,3 +53,19 @@ SHITHUB_AUTH__SMTP__ADDR=127.0.0.1:1025
53
 # anon keyed by remote IP. Zero falls back to the default.
53
 # anon keyed by remote IP. Zero falls back to the default.
54
 SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=5000
54
 SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=5000
55
 SHITHUB_RATELIMIT__API__ANON_PER_HOUR=60
55
 SHITHUB_RATELIMIT__API__ANON_PER_HOUR=60
56
+
57
+# ----- billing (SP03) -----
58
+# Stripe Billing is disabled by default. Use Stripe test-mode keys for local
59
+# drills and keep live keys in the production EnvironmentFile only.
60
+SHITHUB_BILLING__ENABLED=false
61
+SHITHUB_BILLING__GRACE_PERIOD=336h
62
+# Required when billing is enabled:
63
+# SHITHUB_BILLING__STRIPE__SECRET_KEY=sk_test_...
64
+# SHITHUB_BILLING__STRIPE__WEBHOOK_SECRET=whsec_...
65
+# SHITHUB_BILLING__STRIPE__TEAM_PRICE_ID=price_...
66
+# Optional absolute override URLs; omitted values are derived from
67
+# SHITHUB_AUTH__BASE_URL.
68
+# SHITHUB_BILLING__STRIPE__SUCCESS_URL=https://shithub.example/organizations/{org}/billing/success
69
+# SHITHUB_BILLING__STRIPE__CANCEL_URL=https://shithub.example/organizations/{org}/billing/cancel
70
+# SHITHUB_BILLING__STRIPE__PORTAL_RETURN_URL=https://shithub.example/organizations/{org}/settings/billing
71
+SHITHUB_BILLING__STRIPE__AUTOMATIC_TAX=false
docs/internal/billing.mdmodified
@@ -146,6 +146,19 @@ and the migration backfills existing organizations as Free. Subscription
146
 snapshot writes also keep `orgs.plan` synchronized as the
146
 snapshot writes also keep `orgs.plan` synchronized as the
147
 human-facing summary.
147
 human-facing summary.
148
 
148
 
149
+PAYMENTS SP03 adds the first Stripe operator contract:
150
+
151
+- `billing.enabled=false` keeps paid-org flows disabled while retaining
152
+  the local billing tables.
153
+- `billing.stripe.secret_key`, `billing.stripe.webhook_secret`, and
154
+  `billing.stripe.team_price_id` are required before Stripe routes are
155
+  mounted.
156
+- Checkout success, Checkout cancel, and Billing Portal return URLs may
157
+  be overridden explicitly; otherwise the web layer derives absolute
158
+  organization URLs from `auth.base_url`.
159
+- `billing.grace_period` controls how long failed-payment states may
160
+  remain unlocked before paid entitlements are cut off.
161
+
149
 ## Entitlement architecture
162
 ## Entitlement architecture
150
 
163
 
151
 Paid feature checks must live behind a central entitlement package, not
164
 Paid feature checks must live behind a central entitlement package, not
docs/internal/config.mdmodified
@@ -68,6 +68,15 @@ shithubd version # includes a one-line summary of which sinks are confi
68
 | `auth.argon2.time` | uint32 | `3` | argon2id iterations. |
68
 | `auth.argon2.time` | uint32 | `3` | argon2id iterations. |
69
 | `auth.argon2.threads` | uint8 | `2` | argon2id parallelism. |
69
 | `auth.argon2.threads` | uint8 | `2` | argon2id parallelism. |
70
 | `auth.totp_key_b64` | string | `""` | Base64 32-byte AEAD key for at-rest TOTP secrets. Aliased by `SHITHUB_TOTP_KEY`. Empty disables 2FA enrollment routes. |
70
 | `auth.totp_key_b64` | string | `""` | Base64 32-byte AEAD key for at-rest TOTP secrets. Aliased by `SHITHUB_TOTP_KEY`. Empty disables 2FA enrollment routes. |
71
+| `billing.enabled` | bool | `false` | Enables paid-organization Stripe Billing flows. When false, org plan state is local-only. |
72
+| `billing.grace_period` | duration | `336h` | Lock grace window applied after failed subscription payments. |
73
+| `billing.stripe.secret_key` | string | `""` | Stripe secret API key. Required when `billing.enabled=true`. Redacted. |
74
+| `billing.stripe.webhook_secret` | string | `""` | Stripe webhook signing secret. Required when `billing.enabled=true`. Redacted. |
75
+| `billing.stripe.team_price_id` | string | `""` | Stripe recurring Price ID for the Team plan seat. Required when `billing.enabled=true`. |
76
+| `billing.stripe.success_url` | string | `""` | Optional absolute Checkout success URL override. Empty derives from `auth.base_url`. Redacted by `config print`. |
77
+| `billing.stripe.cancel_url` | string | `""` | Optional absolute Checkout cancel URL override. Empty derives from `auth.base_url`. Redacted by `config print`. |
78
+| `billing.stripe.portal_return_url` | string | `""` | Optional absolute Billing Portal return URL override. Empty derives from `auth.base_url`. Redacted by `config print`. |
79
+| `billing.stripe.automatic_tax` | bool | `false` | Enables Stripe Checkout automatic tax collection when the Stripe account is configured for it. |
71
 
80
 
72
 ## Env-var examples
81
 ## Env-var examples
73
 
82
 
@@ -97,6 +106,12 @@ export SHITHUB_SESSION_KEY=$(openssl rand -base64 32)
97
 # Gate /metrics behind Basic auth
106
 # Gate /metrics behind Basic auth
98
 export SHITHUB_METRICS__BASIC_AUTH_USER=prom
107
 export SHITHUB_METRICS__BASIC_AUTH_USER=prom
99
 export SHITHUB_METRICS__BASIC_AUTH_PASS=<long-random>
108
 export SHITHUB_METRICS__BASIC_AUTH_PASS=<long-random>
109
+
110
+# Enable Stripe Billing in test mode
111
+export SHITHUB_BILLING__ENABLED=true
112
+export SHITHUB_BILLING__STRIPE__SECRET_KEY=sk_test_...
113
+export SHITHUB_BILLING__STRIPE__WEBHOOK_SECRET=whsec_...
114
+export SHITHUB_BILLING__STRIPE__TEAM_PRICE_ID=price_...
100
 ```
115
 ```
101
 
116
 
102
 ## Secrets
117
 ## Secrets
go.modmodified
@@ -61,6 +61,7 @@ require (
61
 	github.com/rs/xid v1.6.0 // indirect
61
 	github.com/rs/xid v1.6.0 // indirect
62
 	github.com/sethvargo/go-retry v0.3.0 // indirect
62
 	github.com/sethvargo/go-retry v0.3.0 // indirect
63
 	github.com/spf13/pflag v1.0.9 // indirect
63
 	github.com/spf13/pflag v1.0.9 // indirect
64
+	github.com/stripe/stripe-go/v85 v85.1.0 // indirect
64
 	github.com/tinylib/msgp v1.6.1 // indirect
65
 	github.com/tinylib/msgp v1.6.1 // indirect
65
 	github.com/zeebo/xxh3 v1.1.0 // indirect
66
 	github.com/zeebo/xxh3 v1.1.0 // indirect
66
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
67
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.summodified
@@ -130,6 +130,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
130
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
130
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
131
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
131
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
132
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
132
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
133
+github.com/stripe/stripe-go/v85 v85.1.0 h1:MywWUmgw9M7cnwFFch9cyLmRZuAiYM5cZ27JxH3a5/s=
134
+github.com/stripe/stripe-go/v85 v85.1.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
133
 github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
135
 github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
134
 github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
136
 github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
135
 github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
137
 github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
internal/infra/config/config.gomodified
@@ -16,6 +16,7 @@ package config
16
 import (
16
 import (
17
 	"errors"
17
 	"errors"
18
 	"fmt"
18
 	"fmt"
19
+	"net/url"
19
 	"os"
20
 	"os"
20
 	"reflect"
21
 	"reflect"
21
 	"strconv"
22
 	"strconv"
@@ -39,6 +40,7 @@ type Config struct {
39
 	Auth           AuthConfig           `toml:"auth"`
40
 	Auth           AuthConfig           `toml:"auth"`
40
 	Notif          NotifConfig          `toml:"notif"`
41
 	Notif          NotifConfig          `toml:"notif"`
41
 	RateLimit      RateLimitConfig      `toml:"ratelimit"`
42
 	RateLimit      RateLimitConfig      `toml:"ratelimit"`
43
+	Billing        BillingConfig        `toml:"billing"`
42
 }
44
 }
43
 
45
 
44
 // RateLimitConfig configures runtime rate-limit budgets for surfaces that
46
 // RateLimitConfig configures runtime rate-limit budgets for surfaces that
@@ -71,6 +73,27 @@ type NotifConfig struct {
71
 	UnsubscribeKeyB64 string `toml:"unsubscribe_key_b64"`
73
 	UnsubscribeKeyB64 string `toml:"unsubscribe_key_b64"`
72
 }
74
 }
73
 
75
 
76
+// BillingConfig controls paid-organization enforcement and the payment
77
+// processor integration. Stripe is the first supported processor and remains
78
+// optional until operators provide live or test-mode credentials.
79
+type BillingConfig struct {
80
+	Enabled     bool                `toml:"enabled"`
81
+	GracePeriod time.Duration       `toml:"grace_period"`
82
+	Stripe      StripeBillingConfig `toml:"stripe"`
83
+}
84
+
85
+// StripeBillingConfig holds Stripe Billing API settings. Checkout and portal
86
+// URLs are optional; when omitted the web layer derives them from auth.base_url.
87
+type StripeBillingConfig struct {
88
+	SecretKey       string `toml:"secret_key"`
89
+	WebhookSecret   string `toml:"webhook_secret"`
90
+	TeamPriceID     string `toml:"team_price_id"`
91
+	SuccessURL      string `toml:"success_url"`
92
+	CancelURL       string `toml:"cancel_url"`
93
+	PortalReturnURL string `toml:"portal_return_url"`
94
+	AutomaticTax    bool   `toml:"automatic_tax"`
95
+}
96
+
74
 // WebConfig holds HTTP server settings.
97
 // WebConfig holds HTTP server settings.
75
 type WebConfig struct {
98
 type WebConfig struct {
76
 	Addr            string        `toml:"addr"`
99
 	Addr            string        `toml:"addr"`
@@ -214,6 +237,9 @@ func Defaults() Config {
214
 			SampleRate:  0.05,
237
 			SampleRate:  0.05,
215
 			ServiceName: "shithubd",
238
 			ServiceName: "shithubd",
216
 		},
239
 		},
240
+		Billing: BillingConfig{
241
+			GracePeriod: 14 * 24 * time.Hour,
242
+		},
217
 		Session: SessionConfig{
243
 		Session: SessionConfig{
218
 			MaxAge: 30 * 24 * time.Hour,
244
 			MaxAge: 30 * 24 * time.Hour,
219
 			Secure: false,
245
 			Secure: false,
@@ -359,6 +385,54 @@ func Validate(c *Config) error {
359
 	if c.RateLimit.API.AnonPerHour == 0 {
385
 	if c.RateLimit.API.AnonPerHour == 0 {
360
 		c.RateLimit.API.AnonPerHour = 60
386
 		c.RateLimit.API.AnonPerHour = 60
361
 	}
387
 	}
388
+	if err := validateBilling(c); err != nil {
389
+		return err
390
+	}
391
+	return nil
392
+}
393
+
394
+func validateBilling(c *Config) error {
395
+	if c.Billing.GracePeriod < 0 {
396
+		return fmt.Errorf("config: billing.grace_period: must be >= 0, got %v", c.Billing.GracePeriod)
397
+	}
398
+	for key, raw := range map[string]string{
399
+		"billing.stripe.success_url":       c.Billing.Stripe.SuccessURL,
400
+		"billing.stripe.cancel_url":        c.Billing.Stripe.CancelURL,
401
+		"billing.stripe.portal_return_url": c.Billing.Stripe.PortalReturnURL,
402
+	} {
403
+		if err := validateOptionalHTTPURL(key, raw); err != nil {
404
+			return err
405
+		}
406
+	}
407
+	if !c.Billing.Enabled {
408
+		return nil
409
+	}
410
+	if c.Billing.Stripe.SecretKey == "" {
411
+		return errors.New("config: billing.stripe.secret_key is required when billing.enabled=true")
412
+	}
413
+	if c.Billing.Stripe.WebhookSecret == "" {
414
+		return errors.New("config: billing.stripe.webhook_secret is required when billing.enabled=true")
415
+	}
416
+	if c.Billing.Stripe.TeamPriceID == "" {
417
+		return errors.New("config: billing.stripe.team_price_id is required when billing.enabled=true")
418
+	}
419
+	if err := validateOptionalHTTPURL("auth.base_url", c.Auth.BaseURL); err != nil {
420
+		return err
421
+	}
422
+	return nil
423
+}
424
+
425
+func validateOptionalHTTPURL(key, raw string) error {
426
+	if raw == "" {
427
+		return nil
428
+	}
429
+	u, err := url.Parse(raw)
430
+	if err != nil {
431
+		return fmt.Errorf("config: %s: parse: %w", key, err)
432
+	}
433
+	if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
434
+		return fmt.Errorf("config: %s: must be an absolute http(s) URL", key)
435
+	}
362
 	return nil
436
 	return nil
363
 }
437
 }
364
 
438
 
internal/infra/config/config_test.gomodified
@@ -45,6 +45,49 @@ func TestValidate_RejectsBadSampleRate(t *testing.T) {
45
 	}
45
 	}
46
 }
46
 }
47
 
47
 
48
+func TestValidate_BillingRequiresStripeSettings(t *testing.T) {
49
+	t.Parallel()
50
+	cfg := Defaults()
51
+	cfg.Billing.Enabled = true
52
+	if err := Validate(&cfg); err == nil || !strings.Contains(err.Error(), "billing.stripe.secret_key") {
53
+		t.Fatalf("Validate missing secret key: got %v", err)
54
+	}
55
+
56
+	cfg.Billing.Stripe.SecretKey = "sk_test_123"
57
+	if err := Validate(&cfg); err == nil || !strings.Contains(err.Error(), "billing.stripe.webhook_secret") {
58
+		t.Fatalf("Validate missing webhook secret: got %v", err)
59
+	}
60
+
61
+	cfg.Billing.Stripe.WebhookSecret = "whsec_123"
62
+	if err := Validate(&cfg); err == nil || !strings.Contains(err.Error(), "billing.stripe.team_price_id") {
63
+		t.Fatalf("Validate missing team price: got %v", err)
64
+	}
65
+
66
+	cfg.Billing.Stripe.TeamPriceID = "price_123"
67
+	if err := Validate(&cfg); err != nil {
68
+		t.Fatalf("Validate complete billing config: %v", err)
69
+	}
70
+}
71
+
72
+func TestValidate_BillingRejectsBadURLs(t *testing.T) {
73
+	t.Parallel()
74
+	cfg := Defaults()
75
+	cfg.Billing.Stripe.SuccessURL = "/relative"
76
+	if err := Validate(&cfg); err == nil || !strings.Contains(err.Error(), "billing.stripe.success_url") {
77
+		t.Fatalf("Validate bad success URL: got %v", err)
78
+	}
79
+
80
+	cfg = Defaults()
81
+	cfg.Billing.Enabled = true
82
+	cfg.Billing.Stripe.SecretKey = "sk_test_123"
83
+	cfg.Billing.Stripe.WebhookSecret = "whsec_123"
84
+	cfg.Billing.Stripe.TeamPriceID = "price_123"
85
+	cfg.Auth.BaseURL = "shithub.local"
86
+	if err := Validate(&cfg); err == nil || !strings.Contains(err.Error(), "auth.base_url") {
87
+		t.Fatalf("Validate bad auth base URL with billing enabled: got %v", err)
88
+	}
89
+}
90
+
48
 func TestMergeEnv_AppliesNestedKeys(t *testing.T) {
91
 func TestMergeEnv_AppliesNestedKeys(t *testing.T) {
49
 	t.Parallel()
92
 	t.Parallel()
50
 	cfg := Defaults()
93
 	cfg := Defaults()
@@ -54,6 +97,12 @@ func TestMergeEnv_AppliesNestedKeys(t *testing.T) {
54
 		"SHITHUB_TRACING__ENABLED=true",
97
 		"SHITHUB_TRACING__ENABLED=true",
55
 		"SHITHUB_TRACING__ENDPOINT=http://otel:4318",
98
 		"SHITHUB_TRACING__ENDPOINT=http://otel:4318",
56
 		"SHITHUB_DB__CONNECT_TIMEOUT=8s",
99
 		"SHITHUB_DB__CONNECT_TIMEOUT=8s",
100
+		"SHITHUB_BILLING__ENABLED=true",
101
+		"SHITHUB_BILLING__GRACE_PERIOD=240h",
102
+		"SHITHUB_BILLING__STRIPE__SECRET_KEY=sk_test_123",
103
+		"SHITHUB_BILLING__STRIPE__WEBHOOK_SECRET=whsec_123",
104
+		"SHITHUB_BILLING__STRIPE__TEAM_PRICE_ID=price_123",
105
+		"SHITHUB_BILLING__STRIPE__AUTOMATIC_TAX=true",
57
 	}
106
 	}
58
 	if err := mergeEnv(&cfg, env); err != nil {
107
 	if err := mergeEnv(&cfg, env); err != nil {
59
 		t.Fatalf("mergeEnv: %v", err)
108
 		t.Fatalf("mergeEnv: %v", err)
@@ -70,6 +119,24 @@ func TestMergeEnv_AppliesNestedKeys(t *testing.T) {
70
 	if cfg.DB.ConnectTimeout != 8*time.Second {
119
 	if cfg.DB.ConnectTimeout != 8*time.Second {
71
 		t.Errorf("DB.ConnectTimeout: got %v", cfg.DB.ConnectTimeout)
120
 		t.Errorf("DB.ConnectTimeout: got %v", cfg.DB.ConnectTimeout)
72
 	}
121
 	}
122
+	if !cfg.Billing.Enabled {
123
+		t.Errorf("Billing.Enabled: not set")
124
+	}
125
+	if cfg.Billing.GracePeriod != 240*time.Hour {
126
+		t.Errorf("Billing.GracePeriod: got %v", cfg.Billing.GracePeriod)
127
+	}
128
+	if cfg.Billing.Stripe.SecretKey != "sk_test_123" {
129
+		t.Errorf("Billing.Stripe.SecretKey: got %q", cfg.Billing.Stripe.SecretKey)
130
+	}
131
+	if cfg.Billing.Stripe.WebhookSecret != "whsec_123" {
132
+		t.Errorf("Billing.Stripe.WebhookSecret: got %q", cfg.Billing.Stripe.WebhookSecret)
133
+	}
134
+	if cfg.Billing.Stripe.TeamPriceID != "price_123" {
135
+		t.Errorf("Billing.Stripe.TeamPriceID: got %q", cfg.Billing.Stripe.TeamPriceID)
136
+	}
137
+	if !cfg.Billing.Stripe.AutomaticTax {
138
+		t.Errorf("Billing.Stripe.AutomaticTax: not set")
139
+	}
73
 }
140
 }
74
 
141
 
75
 func TestPrintRedacted_HidesSecrets(t *testing.T) {
142
 func TestPrintRedacted_HidesSecrets(t *testing.T) {
@@ -79,13 +146,15 @@ func TestPrintRedacted_HidesSecrets(t *testing.T) {
79
 	cfg.Session.KeyB64 = "supersecretkey"
146
 	cfg.Session.KeyB64 = "supersecretkey"
80
 	cfg.Metrics.BasicAuthPass = "metrics-pass"
147
 	cfg.Metrics.BasicAuthPass = "metrics-pass"
81
 	cfg.ErrorReporting.DSN = "https://abc@sentry.example/1"
148
 	cfg.ErrorReporting.DSN = "https://abc@sentry.example/1"
149
+	cfg.Billing.Stripe.SecretKey = "sk_live_secret"
150
+	cfg.Billing.Stripe.WebhookSecret = "whsec_secret"
82
 
151
 
83
 	out, err := PrintRedacted(cfg)
152
 	out, err := PrintRedacted(cfg)
84
 	if err != nil {
153
 	if err != nil {
85
 		t.Fatalf("PrintRedacted: %v", err)
154
 		t.Fatalf("PrintRedacted: %v", err)
86
 	}
155
 	}
87
 
156
 
88
-	for _, leak := range []string{"hunter2", "supersecretkey", "metrics-pass", "https://abc@sentry"} {
157
+	for _, leak := range []string{"hunter2", "supersecretkey", "metrics-pass", "https://abc@sentry", "sk_live_secret", "whsec_secret"} {
89
 		if strings.Contains(out, leak) {
158
 		if strings.Contains(out, leak) {
90
 			t.Errorf("PrintRedacted leaked %q\noutput: %s", leak, out)
159
 			t.Errorf("PrintRedacted leaked %q\noutput: %s", leak, out)
91
 		}
160
 		}