Add Stripe billing configuration
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
8f753ab031fbdb53505a1a41d50a9110e16c3690- Parents
-
9d05372 - Tree
df4e048
8f753ab
8f753ab031fbdb53505a1a41d50a9110e16c36909d05372
df4e048| Status | File | + | - |
|---|---|---|---|
| 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 | 53 | # anon keyed by remote IP. Zero falls back to the default. |
| 54 | 54 | SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=5000 |
| 55 | 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 | 146 | snapshot writes also keep `orgs.plan` synchronized as the |
| 147 | 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 | 162 | ## Entitlement architecture |
| 150 | 163 | |
| 151 | 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 | 68 | | `auth.argon2.time` | uint32 | `3` | argon2id iterations. | |
| 69 | 69 | | `auth.argon2.threads` | uint8 | `2` | argon2id parallelism. | |
| 70 | 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 | 81 | ## Env-var examples |
| 73 | 82 | |
@@ -97,6 +106,12 @@ export SHITHUB_SESSION_KEY=$(openssl rand -base64 32) | ||
| 97 | 106 | # Gate /metrics behind Basic auth |
| 98 | 107 | export SHITHUB_METRICS__BASIC_AUTH_USER=prom |
| 99 | 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 | 117 | ## Secrets |
go.modmodified@@ -61,6 +61,7 @@ require ( | ||
| 61 | 61 | github.com/rs/xid v1.6.0 // indirect |
| 62 | 62 | github.com/sethvargo/go-retry v0.3.0 // indirect |
| 63 | 63 | github.com/spf13/pflag v1.0.9 // indirect |
| 64 | + github.com/stripe/stripe-go/v85 v85.1.0 // indirect | |
| 64 | 65 | github.com/tinylib/msgp v1.6.1 // indirect |
| 65 | 66 | github.com/zeebo/xxh3 v1.1.0 // indirect |
| 66 | 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 | 130 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
| 131 | 131 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |
| 132 | 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 | 135 | github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= |
| 134 | 136 | github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= |
| 135 | 137 | github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= |
internal/infra/config/config.gomodified@@ -16,6 +16,7 @@ package config | ||
| 16 | 16 | import ( |
| 17 | 17 | "errors" |
| 18 | 18 | "fmt" |
| 19 | + "net/url" | |
| 19 | 20 | "os" |
| 20 | 21 | "reflect" |
| 21 | 22 | "strconv" |
@@ -39,6 +40,7 @@ type Config struct { | ||
| 39 | 40 | Auth AuthConfig `toml:"auth"` |
| 40 | 41 | Notif NotifConfig `toml:"notif"` |
| 41 | 42 | RateLimit RateLimitConfig `toml:"ratelimit"` |
| 43 | + Billing BillingConfig `toml:"billing"` | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | // RateLimitConfig configures runtime rate-limit budgets for surfaces that |
@@ -71,6 +73,27 @@ type NotifConfig struct { | ||
| 71 | 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 | 97 | // WebConfig holds HTTP server settings. |
| 75 | 98 | type WebConfig struct { |
| 76 | 99 | Addr string `toml:"addr"` |
@@ -214,6 +237,9 @@ func Defaults() Config { | ||
| 214 | 237 | SampleRate: 0.05, |
| 215 | 238 | ServiceName: "shithubd", |
| 216 | 239 | }, |
| 240 | + Billing: BillingConfig{ | |
| 241 | + GracePeriod: 14 * 24 * time.Hour, | |
| 242 | + }, | |
| 217 | 243 | Session: SessionConfig{ |
| 218 | 244 | MaxAge: 30 * 24 * time.Hour, |
| 219 | 245 | Secure: false, |
@@ -359,6 +385,54 @@ func Validate(c *Config) error { | ||
| 359 | 385 | if c.RateLimit.API.AnonPerHour == 0 { |
| 360 | 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 | 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 | 91 | func TestMergeEnv_AppliesNestedKeys(t *testing.T) { |
| 49 | 92 | t.Parallel() |
| 50 | 93 | cfg := Defaults() |
@@ -54,6 +97,12 @@ func TestMergeEnv_AppliesNestedKeys(t *testing.T) { | ||
| 54 | 97 | "SHITHUB_TRACING__ENABLED=true", |
| 55 | 98 | "SHITHUB_TRACING__ENDPOINT=http://otel:4318", |
| 56 | 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 | 107 | if err := mergeEnv(&cfg, env); err != nil { |
| 59 | 108 | t.Fatalf("mergeEnv: %v", err) |
@@ -70,6 +119,24 @@ func TestMergeEnv_AppliesNestedKeys(t *testing.T) { | ||
| 70 | 119 | if cfg.DB.ConnectTimeout != 8*time.Second { |
| 71 | 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 | 142 | func TestPrintRedacted_HidesSecrets(t *testing.T) { |
@@ -79,13 +146,15 @@ func TestPrintRedacted_HidesSecrets(t *testing.T) { | ||
| 79 | 146 | cfg.Session.KeyB64 = "supersecretkey" |
| 80 | 147 | cfg.Metrics.BasicAuthPass = "metrics-pass" |
| 81 | 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 | 152 | out, err := PrintRedacted(cfg) |
| 84 | 153 | if err != nil { |
| 85 | 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 | 158 | if strings.Contains(out, leak) { |
| 90 | 159 | t.Errorf("PrintRedacted leaked %q\noutput: %s", leak, out) |
| 91 | 160 | } |