From ecd689a7e15566813e6ad0c2b5e4a98a869b406a Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 18 May 2026 22:10:20 -0500 Subject: [PATCH] billing improvements --- Makefile | 13 +- config-appview.example.yaml | 4 +- docs/BILLING.md | 11 +- internal/testharness/harness.go | 21 +- pkg/appview/handlers/base.go | 3 +- pkg/appview/handlers/settings.go | 34 +- pkg/appview/routes/routes.go | 15 +- pkg/billing/billing.go | 142 +++++- pkg/billing/events.go | 56 +++ test/stripe-integration/env.go | 180 ++++++++ test/stripe-integration/manager_test.go | 571 ++++++++++++++++++++++++ test/stripe-integration/webhook_test.go | 135 ++++++ 12 files changed, 1146 insertions(+), 39 deletions(-) create mode 100644 pkg/billing/events.go create mode 100644 test/stripe-integration/env.go create mode 100644 test/stripe-integration/manager_test.go create mode 100644 test/stripe-integration/webhook_test.go diff --git a/Makefile b/Makefile index ba7fccf..69cb846 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ build-trixie \ - generate test test-race test-verbose lint lex-lint clean help install-credential-helper \ + generate test test-race test-verbose integration-test stripe-integration-test \ + lint lex-lint clean help install-credential-helper \ develop develop-detached develop-down dev \ docker docker-appview docker-hold docker-scanner @@ -81,7 +82,7 @@ build-trixie: $(GENERATED_ASSETS) ## Build all production binaries (appview, hol $(TRIXIE_BUILDER_IMAGE) \ bash -c '\ set -e && \ - go build -trimpath -ldflags="-s -w $(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview && \ + go build -trimpath -tags billing -ldflags="-s -w $(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview && \ go build -trimpath -ldflags="-s -w" -o bin/atcr-hold ./cmd/hold && \ go build -trimpath -ldflags="-s -w" -o bin/docker-credential-atcr ./cmd/credential-helper && \ go build -trimpath -ldflags="-s -w" -o bin/atcr-labeler ./cmd/labeler && \ @@ -106,6 +107,14 @@ integration-test: ## Run in-process smoke test (no docker, fake PDS + gofakes3 + @echo "→ Running integration smoke test..." go test -tags=integration -count=1 -race -timeout=120s ./test/integration/... +stripe-integration-test: ## Run Stripe sandbox-backed billing tests (needs STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_TEST_PRICE_MONTHLY, STRIPE_TEST_PRICE_YEARLY) + @echo "→ Running Stripe sandbox integration tests..." + @echo " Required env: STRIPE_SECRET_KEY (sk_test_...), STRIPE_WEBHOOK_SECRET (whsec_...)," + @echo " STRIPE_TEST_PRICE_MONTHLY, STRIPE_TEST_PRICE_YEARLY" + @echo " Optional env: STRIPE_TEST_TIER_NAME (default 'Supporter')," + @echo " STRIPE_TEST_EXISTING_CUSTOMER_DID (skips portal search-lag wait)" + go test -tags="billing stripe_integration" -count=1 -timeout=180s ./test/stripe-integration/... + ##@ Quality Targets .PHONY: check-golangci-lint diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 33b3c7d..17f8029 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -111,7 +111,7 @@ billing: # Subscription tiers ordered by rank (lowest to highest). tiers: - # Tier name. Position in list determines rank (0-based). - name: free + name: Free # Short description shown on the plan card. description: Get started with basic storage # List of features included in this tier. @@ -147,7 +147,7 @@ billing: # Show supporter badge on user profiles for subscribers at this tier. supporter_badge: true - # Tier name. Position in list determines rank (0-based). - name: bosun + name: Pro # Short description shown on the plan card. description: More storage with scan-on-push # List of features included in this tier. diff --git a/docs/BILLING.md b/docs/BILLING.md index 3c11c11..20e45fe 100644 --- a/docs/BILLING.md +++ b/docs/BILLING.md @@ -158,17 +158,20 @@ stripe trigger customer.subscription.deleted ## Webhook Events -The appview billing manager handles these Stripe events: +The canonical list of subscribed events lives in `pkg/billing/events.go` as the +`SubscribedEvents` slice. Use it when configuring a Stripe Dashboard webhook +endpoint or auditing an existing one. | Event | Action | |-------|--------| -| `checkout.session.completed` | Create/update subscription, set tier | +| `checkout.session.completed` | No-op (subscription.created does the tier work) | | `customer.subscription.created` | Set crew tier from price ID | -| `customer.subscription.updated` | Update crew tier if price changed | +| `customer.subscription.updated` | Update tier; handles `past_due` (keep), `unpaid` / `incomplete_expired` (downgrade), `incomplete` (await) | | `customer.subscription.paused` | Downgrade to free tier | | `customer.subscription.resumed` | Restore tier from subscription price | | `customer.subscription.deleted` | Downgrade to free tier | -| `invoice.payment_failed` | Log warning (tier unchanged until canceled) | +| `invoice.payment_failed` | Log only (Stripe Smart Retries handle retry + customer email) | +| `charge.dispute.created` | Log only (Stripe emails the account owner by default) | ## Plankowners (Grandfathering) diff --git a/internal/testharness/harness.go b/internal/testharness/harness.go index 187dc4e..77285bc 100644 --- a/internal/testharness/harness.go +++ b/internal/testharness/harness.go @@ -29,6 +29,7 @@ import ( "atcr.io/pkg/appview" "atcr.io/pkg/atproto" atprotodid "atcr.io/pkg/atproto/did" + "atcr.io/pkg/billing" "atcr.io/pkg/hold" "atcr.io/pkg/hold/quota" "atcr.io/pkg/testpds" @@ -38,7 +39,8 @@ import ( type Option func(*options) type options struct { - quota *quota.Config + quota *quota.Config + billing *billing.Config } // WithQuotaTiers configures the hold's quota manager with the given tier @@ -55,6 +57,16 @@ func WithQuotaTiers(tiers []quota.TierConfig, newCrewTier string) Option { } } +// WithBilling wires a billing.Config into the appview built by the harness. +// Only meaningful under `-tags billing`: without the tag the billing package +// compiles to no-op stubs and Manager.Enabled() stays false regardless of +// what's set here. +func WithBilling(cfg billing.Config) Option { + return func(o *options) { + o.billing = &cfg + } +} + // Harness owns all in-process servers and tears them down on test cleanup. type Harness struct { t *testing.T @@ -62,7 +74,8 @@ type Harness struct { S3URL string HoldDID string HoldURL string - AppViewURL string + AppViewURL string // 127.0.0.1:PORT — host used for /v2/ registry requests + UIBaseURL string // localhost:PORT — host used for UI/API routes (e.g. /api/stripe/webhook) AppView *appview.AppViewServer Hold *hold.HoldServer Captain *Sailor // hold owner; set before appview boots @@ -223,9 +236,13 @@ func New(t *testing.T, opts ...Option) *Harness { } avBaseURL := "http://localhost:" + avPort h.AppViewURL = "http://" + avAddr + h.UIBaseURL = avBaseURL avDBPath := filepath.Join(t.TempDir(), "appview.db") avCfg := buildAppViewConfig(avAddr, avBaseURL, h.HoldDID, avDBPath) + if o.billing != nil { + avCfg.Billing = *o.billing + } avSrv, err := appview.NewAppViewServer(avCfg, nil) if err != nil { avListener.Close() diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 4f467f0..3276128 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -47,6 +47,7 @@ type BaseUIHandler struct { Jurisdiction string ClientName string // Full name: "AT Container Registry" ClientShortName string // Short name: "ATCR" - AIAdvisorEnabled bool // True when Claude API key is configured + AIAdvisorEnabled bool // True when billing is fully configured AND Claude API key is set + BillingEnabled bool // True when the billing build is compiled in and Stripe is configured SourceURL string // Source code URL for the footer "Source" link } diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 413645c..9798f98 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -45,20 +45,28 @@ type settingsTab struct { Label string } -func settingsTabs() []settingsTab { - return []settingsTab{ - {Slug: "user", Label: "User"}, - {Slug: "billing", Label: "Billing"}, - {Slug: "storage", Label: "Storage"}, - {Slug: "devices", Label: "Devices"}, - {Slug: "webhooks", Label: "Webhooks"}, - {Slug: "advanced", Label: "Advanced"}, +func settingsTabs(billingEnabled bool) []settingsTab { + tabs := []settingsTab{{Slug: "user", Label: "User"}} + if billingEnabled { + tabs = append(tabs, settingsTab{Slug: "billing", Label: "Billing"}) } + tabs = append(tabs, + settingsTab{Slug: "storage", Label: "Storage"}, + settingsTab{Slug: "devices", Label: "Devices"}, + settingsTab{Slug: "webhooks", Label: "Webhooks"}, + settingsTab{Slug: "advanced", Label: "Advanced"}, + ) + return tabs } -var validSettingsTabs = map[string]bool{ - "user": true, "storage": true, "billing": true, - "devices": true, "webhooks": true, "advanced": true, +func isValidSettingsTab(tab string, billingEnabled bool) bool { + switch tab { + case "user", "storage", "devices", "webhooks", "advanced": + return true + case "billing": + return billingEnabled + } + return false } // settingsProfile is the sidebar identity info shared across all tabs. @@ -100,7 +108,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // If HX-Request is set, only the panel fragment is rendered. func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !validSettingsTabs[tab] { + if !isValidSettingsTab(tab, h.BillingEnabled) { http.NotFound(w, r) return } @@ -135,7 +143,7 @@ func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { PageData: NewPageData(r, &h.BaseUIHandler), Meta: meta, ActiveTab: tab, - Tabs: settingsTabs(), + Tabs: settingsTabs(h.BillingEnabled), Profile: settingsProfile{ Handle: user.Handle, DID: user.DID, diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 8dfa135..e3af3dc 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -81,7 +81,8 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { Jurisdiction: deps.LegalConfig.Jurisdiction, ClientName: deps.ClientName, ClientShortName: deps.ClientShortName, - AIAdvisorEnabled: deps.ClaudeAPIKey != "", + BillingEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled(), + AIAdvisorEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled() && deps.ClaudeAPIKey != "", SourceURL: deps.SourceURL, } @@ -174,9 +175,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP) router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP) - router.Get("/api/image-advisor/{handle}/*", middleware.RequireAuth(deps.SessionStore, deps.Database)( - &uihandlers.ImageAdvisorHandler{BaseUIHandler: base, ClaudeAPIKey: deps.ClaudeAPIKey}, - ).ServeHTTP) + if base.AIAdvisorEnabled { + router.Get("/api/image-advisor/{handle}/*", middleware.RequireAuth(deps.SessionStore, deps.Database)( + &uihandlers.ImageAdvisorHandler{BaseUIHandler: base, ClaudeAPIKey: deps.ClaudeAPIKey}, + ).ServeHTTP) + } // Diff page: /diff/{handle}/{repo}?from=...&to=... router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( @@ -195,7 +198,9 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { r.Get("/settings", settings.ServeHTTP) r.Get("/settings/user", settings.ServeTab("user")) r.Get("/settings/storage", settings.ServeTab("storage")) - r.Get("/settings/billing", settings.ServeTab("billing")) + if base.BillingEnabled { + r.Get("/settings/billing", settings.ServeTab("billing")) + } r.Get("/settings/devices", settings.ServeTab("devices")) r.Get("/settings/webhooks", settings.ServeTab("webhooks")) r.Get("/settings/advanced", settings.ServeTab("advanced")) diff --git a/pkg/billing/billing.go b/pkg/billing/billing.go index 0f573f7..34f16b8 100644 --- a/pkg/billing/billing.go +++ b/pkg/billing/billing.go @@ -406,14 +406,18 @@ func (m *Manager) HandleWebhook(r *http.Request) error { } switch event.Type { - case "checkout.session.completed": + case EventCheckoutSessionCompleted: m.handleCheckoutCompleted(event) - case "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "customer.subscription.paused", - "customer.subscription.resumed": + case EventSubscriptionCreated, + EventSubscriptionUpdated, + EventSubscriptionDeleted, + EventSubscriptionPaused, + EventSubscriptionResumed: m.handleSubscriptionChange(event) + case EventInvoicePaymentFailed: + m.handleInvoicePaymentFailed(event) + case EventChargeDisputeCreated: + m.handleChargeDisputeCreated(event) default: slog.Debug("Ignoring Stripe event", "type", event.Type) } @@ -454,17 +458,43 @@ func (m *Manager) handleSubscriptionChange(event stripe.Event) { var tierRank int switch sub.Status { - case stripe.SubscriptionStatusActive: + case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing: if sub.Items != nil && len(sub.Items.Data) > 0 { priceID := sub.Items.Data[0].Price.ID tierName, tierRank = m.cfg.GetTierByPriceID(priceID) } - case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused: - // Revert to free tier (rank 0) + + case stripe.SubscriptionStatusPastDue: + // Stripe is retrying the card via Smart Retries (typically 1-3 weeks). + // Keep tier unchanged; customer receives dunning emails from Stripe. + // A later subscription.updated with unpaid will downgrade. + slog.Warn("Subscription past due, keeping tier during dunning", + "userDID", userDID, + "subscriptionID", sub.ID, + "customerID", sub.Customer.ID, + ) + return + + case stripe.SubscriptionStatusUnpaid, + stripe.SubscriptionStatusCanceled, + stripe.SubscriptionStatusPaused, + stripe.SubscriptionStatusIncompleteExpired: + // Dunning exhausted, cleanly canceled, paused without payment method, + // or initial payment never completed within the 23-hour window. tierName = m.cfg.Tiers[0].Name tierRank = 0 + + case stripe.SubscriptionStatusIncomplete: + // 23-hour window for initial payment to confirm. Don't grant tier yet: + // a later subscription.updated will fire with active or incomplete_expired. + slog.Info("Subscription incomplete, awaiting initial payment", + "userDID", userDID, + "subscriptionID", sub.ID, + ) + return + default: - slog.Debug("Ignoring subscription status", "status", sub.Status) + slog.Debug("Ignoring subscription status", "status", sub.Status, "subscriptionID", sub.ID) return } @@ -496,6 +526,98 @@ func (m *Manager) handleSubscriptionChange(event stripe.Event) { m.customerCacheMu.Unlock() } +// handleInvoicePaymentFailed logs a failed invoice payment. +// No tier change: handleSubscriptionChange reacts to the subsequent +// past_due → unpaid transition. Stripe Smart Retries handle retry cadence +// and customer-facing dunning emails. +func (m *Manager) handleInvoicePaymentFailed(event stripe.Event) { + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + slog.Error("Failed to parse invoice", "error", err) + return + } + + customerID := "" + userDID := "" + if inv.Customer != nil { + customerID = inv.Customer.ID + userDID = m.getCustomerDID(customerID) + } + + nextAttempt := int64(0) + if inv.NextPaymentAttempt != 0 { + nextAttempt = inv.NextPaymentAttempt + } + + slog.Warn("Stripe invoice payment failed", + "userDID", userDID, + "customerID", customerID, + "invoiceID", inv.ID, + "subscriptionID", subscriptionIDFromInvoice(&inv), + "amountDue", inv.AmountDue, + "currency", inv.Currency, + "attemptCount", inv.AttemptCount, + "nextAttempt", nextAttempt, + ) +} + +// handleChargeDisputeCreated logs a new chargeback. +// No tier action: disputes can be won, and downgrade-then-revert is worse UX +// than waiting for resolution. Stripe emails the account owner by default +// (Dashboard → Settings → Team & security → Notifications → "Disputes and +// inquiries"). +func (m *Manager) handleChargeDisputeCreated(event stripe.Event) { + var dispute stripe.Dispute + if err := json.Unmarshal(event.Data.Raw, &dispute); err != nil { + slog.Error("Failed to parse dispute", "error", err) + return + } + + customerID := "" + userDID := "" + if dispute.Charge != nil && dispute.Charge.Customer != nil { + customerID = dispute.Charge.Customer.ID + userDID = m.getCustomerDID(customerID) + } + + evidenceDueBy := int64(0) + if dispute.EvidenceDetails != nil { + evidenceDueBy = dispute.EvidenceDetails.DueBy + } + + slog.Warn("Stripe chargeback opened", + "userDID", userDID, + "customerID", customerID, + "disputeID", dispute.ID, + "chargeID", chargeIDFromDispute(&dispute), + "amount", dispute.Amount, + "currency", dispute.Currency, + "reason", dispute.Reason, + "status", dispute.Status, + "evidenceDueBy", evidenceDueBy, + ) +} + +// subscriptionIDFromInvoice returns the subscription ID on an invoice, or "". +// In stripe-go v84+, the subscription lives under Invoice.Parent.SubscriptionDetails. +func subscriptionIDFromInvoice(inv *stripe.Invoice) string { + if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil { + return "" + } + if inv.Parent.SubscriptionDetails.Subscription == nil { + return "" + } + return inv.Parent.SubscriptionDetails.Subscription.ID +} + +// chargeIDFromDispute returns the charge ID on a dispute, or "". +func chargeIDFromDispute(d *stripe.Dispute) string { + if d == nil || d.Charge == nil { + return "" + } + return d.Charge.ID +} + // getOrCreateCustomer finds or creates a Stripe customer for a DID. func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) { // Check cache diff --git a/pkg/billing/events.go b/pkg/billing/events.go new file mode 100644 index 0000000..5789744 --- /dev/null +++ b/pkg/billing/events.go @@ -0,0 +1,56 @@ +//go:build billing + +package billing + +// Stripe webhook event types the appview subscribes to. +// +// When adding or removing an entry here, update four places: +// 1. The switch in HandleWebhook (billing.go) +// 2. The SubscribedEvents slice below +// 3. The Stripe Dashboard endpoint subscription list +// 4. docs/BILLING.md "Webhook Events" table +const ( + // EventCheckoutSessionCompleted fires when a customer finishes Stripe Checkout. + // No-op handler: customer.subscription.created does the actual tier work. + EventCheckoutSessionCompleted = "checkout.session.completed" + + // EventSubscriptionCreated fires on initial subscription creation. + EventSubscriptionCreated = "customer.subscription.created" + + // EventSubscriptionUpdated fires on plan change, renewal, status transition. + // Workhorse event: covers active→past_due, past_due→unpaid, resumes, plan + // upgrades/downgrades, etc. + EventSubscriptionUpdated = "customer.subscription.updated" + + // EventSubscriptionDeleted fires when the subscription is fully canceled. + EventSubscriptionDeleted = "customer.subscription.deleted" + + // EventSubscriptionPaused fires when a trial ended without a payment method. + EventSubscriptionPaused = "customer.subscription.paused" + + // EventSubscriptionResumed fires when a paused subscription resumes. + EventSubscriptionResumed = "customer.subscription.resumed" + + // EventInvoicePaymentFailed fires when a card declines (initial or renewal). + // Logged only. Stripe Smart Retries handle retries and email the customer. + // Tier stays put; subscription.updated handles past_due/unpaid transitions. + EventInvoicePaymentFailed = "invoice.payment_failed" + + // EventChargeDisputeCreated fires when a chargeback is opened. + // Logged only. Stripe emails the account owner about disputes by default. + EventChargeDisputeCreated = "charge.dispute.created" +) + +// SubscribedEvents is the full set of events that must be enabled on the +// Stripe Dashboard webhook endpoint. Use this when configuring a new endpoint +// or auditing an existing one. +var SubscribedEvents = []string{ + EventCheckoutSessionCompleted, + EventSubscriptionCreated, + EventSubscriptionUpdated, + EventSubscriptionDeleted, + EventSubscriptionPaused, + EventSubscriptionResumed, + EventInvoicePaymentFailed, + EventChargeDisputeCreated, +} diff --git a/test/stripe-integration/env.go b/test/stripe-integration/env.go new file mode 100644 index 0000000..1167b55 --- /dev/null +++ b/test/stripe-integration/env.go @@ -0,0 +1,180 @@ +//go:build billing && stripe_integration + +// Package stripeintegration holds the Stripe sandbox-backed integration test +// suite. Everything here is gated behind both the `billing` and +// `stripe_integration` build tags so it never compiles into production +// binaries or default `go test ./...` runs in CI. +// +// To run locally: +// +// source ../../../atcr-secrets.env +// export STRIPE_TEST_PRICE_MONTHLY=price_... +// export STRIPE_TEST_PRICE_YEARLY=price_... +// make stripe-integration-test +package stripeintegration + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "testing" + + "github.com/stripe/stripe-go/v84" + "github.com/stripe/stripe-go/v84/customer" +) + +// stripeEnv holds the resolved sandbox configuration for a test run. +type stripeEnv struct { + SecretKey string // sk_test_... (live keys are rejected) + WebhookSecret string // whsec_... + PriceMonthly string // recurring price ID with interval=month + PriceYearly string // recurring price ID with interval=year + TierName string // logical name for the tier holding PriceMonthly/PriceYearly +} + +// requireStripeEnv returns the resolved Stripe sandbox configuration. If any +// required variable is missing or obviously wrong (e.g. a live key in a test +// context), it calls t.Fatalf with the full list — opting into the +// `stripe_integration` build tag is the contract for committing to set these, +// so a hard failure (rather than t.Skip) is intentional. +func requireStripeEnv(t *testing.T) stripeEnv { + t.Helper() + + env := stripeEnv{ + SecretKey: os.Getenv("STRIPE_SECRET_KEY"), + WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + PriceMonthly: os.Getenv("STRIPE_TEST_PRICE_MONTHLY"), + PriceYearly: os.Getenv("STRIPE_TEST_PRICE_YEARLY"), + TierName: getenvDefault("STRIPE_TEST_TIER_NAME", "Supporter"), + } + + var missing []string + if env.SecretKey == "" { + missing = append(missing, "STRIPE_SECRET_KEY (sk_test_...)") + } + if env.WebhookSecret == "" { + missing = append(missing, "STRIPE_WEBHOOK_SECRET (whsec_...)") + } + if env.PriceMonthly == "" { + missing = append(missing, "STRIPE_TEST_PRICE_MONTHLY (recurring price ID, interval=month)") + } + if env.PriceYearly == "" { + missing = append(missing, "STRIPE_TEST_PRICE_YEARLY (recurring price ID, interval=year)") + } + if len(missing) > 0 { + t.Fatalf("Stripe integration tests need the following env vars:\n - %s\n\n"+ + "Set them (e.g. via ../../atcr-secrets.env) and re-run with `make stripe-integration-test`.", + strings.Join(missing, "\n - ")) + } + + if !strings.HasPrefix(env.SecretKey, "sk_test_") { + t.Fatalf("STRIPE_SECRET_KEY must be a sandbox key (sk_test_...); refusing to run against a live account.") + } + if !strings.HasPrefix(env.WebhookSecret, "whsec_") { + t.Fatalf("STRIPE_WEBHOOK_SECRET does not look like a Stripe signing secret (expected whsec_...).") + } + + return env +} + +// uniqueDID generates a one-off DID per test so customer lookups can't +// collide with parallel runs or stale sandbox state. The DID is just a +// metadata string for Stripe — no PDS resolution happens here. +func uniqueDID(t *testing.T) string { + t.Helper() + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("random DID: %v", err) + } + return "did:web:stripe-test." + hex.EncodeToString(b[:]) + ".local" +} + +// cleanupCustomerByDID schedules a t.Cleanup that searches Stripe for any +// customer whose metadata.user_did matches and deletes it. Several entry +// points (CreateCheckoutSession, GetSubscriptionInfo) create customers as +// side effects without returning the ID, so we re-search at teardown rather +// than tracking IDs through every code path. The search index lags writes by +// minutes but cleanup runs at the end of the test, by which point the index +// has usually caught up. Stragglers can be removed with the periodic sandbox +// cleanup script. +func cleanupCustomerByDID(t *testing.T, userDID string) { + t.Helper() + t.Cleanup(func() { + params := &stripe.CustomerSearchParams{ + SearchParams: stripe.SearchParams{ + Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), + }, + } + iter := customer.Search(params) + for iter.Next() { + id := iter.Customer().ID + if _, err := customer.Del(id, nil); err != nil { + t.Logf("cleanup: failed to delete customer %s: %v", id, err) + } + } + }) +} + +func getenvDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// logCapture installs a slog handler that records every log line into an +// in-memory buffer, and registers a t.Cleanup to restore the previous +// default. Tests use this to verify which branch of HandleWebhook's switch +// fired — each branch has a distinctive log signature, and the default +// "Ignoring Stripe event" branch logs at DEBUG, so capturing at DEBUG level +// distinguishes "dispatched to handler" from "dropped to default". +// +// slog.SetDefault is global, so callers must not t.Parallel. +type logCapture struct { + mu sync.Mutex + buf bytes.Buffer +} + +func newLogCapture(t *testing.T) *logCapture { + t.Helper() + c := &logCapture{} + handler := slog.NewTextHandler(&lockedWriter{c: c}, &slog.HandlerOptions{Level: slog.LevelDebug}) + prev := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(prev) }) + return c +} + +// reset discards captured output so the next assertion starts clean. +func (c *logCapture) reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.buf.Reset() +} + +// contains reports whether the captured output contains s. +func (c *logCapture) contains(s string) bool { + c.mu.Lock() + defer c.mu.Unlock() + return strings.Contains(c.buf.String(), s) +} + +// String returns the full captured output, for use in failure messages. +func (c *logCapture) String() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.String() +} + +type lockedWriter struct{ c *logCapture } + +func (w *lockedWriter) Write(p []byte) (int, error) { + w.c.mu.Lock() + defer w.c.mu.Unlock() + return w.c.buf.Write(p) +} diff --git a/test/stripe-integration/manager_test.go b/test/stripe-integration/manager_test.go new file mode 100644 index 0000000..cb44b6d --- /dev/null +++ b/test/stripe-integration/manager_test.go @@ -0,0 +1,571 @@ +//go:build billing && stripe_integration + +package stripeintegration + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + stripe "github.com/stripe/stripe-go/v84" + "github.com/stripe/stripe-go/v84/customer" + "github.com/stripe/stripe-go/v84/webhook" + + "atcr.io/pkg/billing" +) + +// newManager builds a billing.Manager bound to the sandbox account. +// managedHolds is intentionally empty: the webhook-driven +// UpdateCrewTierOnAllHolds call becomes a no-op, which is what we want for +// hermetic Manager-level tests. The hold-side wiring is exercised separately +// by the harness E2E test in this same package. +func newManager(t *testing.T, env stripeEnv) *billing.Manager { + t.Helper() + cfg := &billing.Config{ + StripeSecretKey: env.SecretKey, + WebhookSecret: env.WebhookSecret, + Currency: "usd", + SuccessURL: "{base_url}/billing/success", + CancelURL: "{base_url}/billing/cancel", + Tiers: []billing.BillingTierConfig{ + {Name: "free", Description: "Free tier", MaxWebhooks: 1}, + { + Name: env.TierName, + Description: "Paid tier", + StripePriceMonthly: env.PriceMonthly, + StripePriceYearly: env.PriceYearly, + MaxWebhooks: 10, + WebhookAllTriggers: true, + SupporterBadge: true, + }, + }, + } + return billing.New(cfg, nil, "did:web:test-appview.local", nil, "http://test-appview.local") +} + +func TestManagerEnabled(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + if !m.Enabled() { + t.Fatalf("manager should be Enabled() with sandbox config + billing build tag") + } +} + +// TestGetSubscriptionInfoNoCustomer covers the cold-cache path: a DID with +// no Stripe customer should resolve to the lowest-rank tier and still come +// back populated with live monthly/yearly prices fetched from Stripe. +func TestGetSubscriptionInfoNoCustomer(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + info, err := m.GetSubscriptionInfo(uniqueDID(t)) + if err != nil { + t.Fatalf("GetSubscriptionInfo: %v", err) + } + if info.CurrentTier != "free" { + t.Errorf("CurrentTier = %q, want %q", info.CurrentTier, "free") + } + if info.TierRank != 0 { + t.Errorf("TierRank = %d, want 0", info.TierRank) + } + if len(info.Tiers) != 2 { + t.Fatalf("expected 2 tiers, got %d", len(info.Tiers)) + } + if info.Tiers[1].PriceCentsMonthly <= 0 { + t.Errorf("paid tier monthly price not populated from Stripe: %+v", info.Tiers[1]) + } + if info.Tiers[1].PriceCentsYearly <= 0 { + t.Errorf("paid tier yearly price not populated from Stripe: %+v", info.Tiers[1]) + } +} + +// TestCreateCheckoutSessionMonthly exercises the full checkout-session flow: +// customer creation as a side effect, line-item construction, and the URL +// shape Stripe returns. The created customer is cleaned up via metadata +// search at teardown. +func TestCreateCheckoutSessionMonthly(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + userDID := uniqueDID(t) + cleanupCustomerByDID(t, userDID) + + req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil) + resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test-handle", &billing.CheckoutSessionRequest{ + Tier: env.TierName, + Interval: "monthly", + }) + if err != nil { + t.Fatalf("CreateCheckoutSession: %v", err) + } + if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") { + t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL) + } + if resp.SessionID == "" { + t.Error("SessionID empty") + } +} + +// TestCreateCheckoutSessionYearly confirms the interval flag actually selects +// the yearly price when present (the manager falls back to monthly otherwise, +// which we don't want to silently mask). +func TestCreateCheckoutSessionYearly(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + userDID := uniqueDID(t) + cleanupCustomerByDID(t, userDID) + + req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil) + resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test", &billing.CheckoutSessionRequest{ + Tier: env.TierName, + Interval: "yearly", + }) + if err != nil { + t.Fatalf("CreateCheckoutSession(yearly): %v", err) + } + if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") { + t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL) + } +} + +func TestCreateCheckoutSessionUnknownTier(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil) + _, err := m.CreateCheckoutSession(req, uniqueDID(t), "h", &billing.CheckoutSessionRequest{ + Tier: "tier-does-not-exist", + }) + if err == nil { + t.Fatal("expected error for unknown tier, got nil") + } + if !strings.Contains(err.Error(), "unknown tier") { + t.Errorf("error = %v, want one containing 'unknown tier'", err) + } +} + +// TestGetBillingPortalURL exercises the billing portal session creation. +// The portal lookup goes through Stripe's customer search API, which is +// eventually consistent (typically ~minutes), so a freshly created customer +// often isn't findable yet. We poll up to the documented worst-case and +// surface the lag as a t.Skip rather than a failure — it isn't a bug in the +// code under test, just a property of the sandbox API. +// +// To make this test reliably pass in a tight loop, set +// STRIPE_TEST_EXISTING_CUSTOMER_DID to a DID whose customer already lives in +// the search index (e.g. one created by a prior run). +func TestGetBillingPortalURL(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + userDID := getenvDefault("STRIPE_TEST_EXISTING_CUSTOMER_DID", "") + if userDID == "" { + userDID = uniqueDID(t) + cleanupCustomerByDID(t, userDID) + + // Create a customer so the portal call has something to look up. + // CreateCheckoutSession is the simplest public path that creates a + // customer; we discard the URL it returns. + req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil) + if _, err := m.CreateCheckoutSession(req, userDID, "portal-test", &billing.CheckoutSessionRequest{ + Tier: env.TierName, + Interval: "monthly", + }); err != nil { + t.Fatalf("seed customer via CreateCheckoutSession: %v", err) + } + } + + deadline := time.Now().Add(90 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + resp, err := m.GetBillingPortalURL(userDID, "https://test-appview.local/settings/billing") + if err == nil { + if !strings.HasPrefix(resp.PortalURL, "https://billing.stripe.com/") { + t.Errorf("PortalURL = %q, want https://billing.stripe.com/...", resp.PortalURL) + } + return + } + lastErr = err + if strings.Contains(err.Error(), "configuration") || strings.Contains(err.Error(), "No configuration") { + t.Skipf("Stripe billing portal is not configured for the sandbox account. "+ + "Set it up at https://dashboard.stripe.com/test/settings/billing/portal then re-run. (raw: %v)", err) + } + if !strings.Contains(err.Error(), "no billing account found") { + t.Fatalf("GetBillingPortalURL: unexpected error %v", err) + } + time.Sleep(3 * time.Second) + } + t.Skipf("Stripe customer search index did not surface the new customer within 90s "+ + "(last error: %v). This is an eventual-consistency property of the search API, not a "+ + "code-under-test bug. Set STRIPE_TEST_EXISTING_CUSTOMER_DID to a pre-existing DID to "+ + "skip this wait.", lastErr) +} + +// TestHandleWebhookValidSignature confirms the happy path: a payload signed +// with the configured webhook secret is accepted, parsed, and dispatched to +// the right branch (checkout.session.completed is logged-only today). +func TestHandleWebhookValidSignature(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + payload := buildEventPayload("evt_test_checkout", "checkout.session.completed", map[string]any{ + "id": "cs_test_x", + "object": "checkout.session", + "customer": "cus_fake", + "subscription": "sub_fake", + }) + + req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now()) + if err := m.HandleWebhook(req); err != nil { + t.Fatalf("HandleWebhook(valid signature): %v", err) + } +} + +func TestHandleWebhookInvalidSignature(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{}) + + // Sign with a deliberately wrong secret so ConstructEvent rejects it. + req := signedWebhookRequest(t, payload, "whsec_wrong_secret_for_test", time.Now()) + err := m.HandleWebhook(req) + if err == nil { + t.Fatal("expected signature verification to fail, got nil") + } + if !strings.Contains(err.Error(), "signature") { + t.Errorf("error = %v, want one mentioning 'signature'", err) + } +} + +func TestHandleWebhookStaleTimestamp(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + + payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{}) + + // 10 minutes in the past — beyond Stripe's default 5-minute tolerance. + req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now().Add(-10*time.Minute)) + err := m.HandleWebhook(req) + if err == nil { + t.Fatal("expected stale-timestamp rejection, got nil") + } + if !errors.Is(err, webhook.ErrTooOld) && !strings.Contains(err.Error(), "signature") { + t.Errorf("error = %v, want timestamp/signature failure", err) + } +} + +// TestHandleWebhookSubscriptionCreated drives the subscription lifecycle +// branch with a hand-built payload. We can't easily create a real Stripe +// subscription in the sandbox without a payment method on the customer, so +// we fabricate an event whose shape matches what Stripe sends. The handler +// looks up the user_did from the customer metadata, so a real customer must +// exist behind the cus_... ID in the payload. +func TestHandleWebhookSubscriptionCreated(t *testing.T) { + env := requireStripeEnv(t) + // Set the global Stripe key so direct customer.New calls below + // authenticate. billing.New sets stripe.Key as a side effect, but + // belt-and-suspenders. + stripe.Key = env.SecretKey + m := newManager(t, env) + userDID := uniqueDID(t) + + // Create the Stripe customer directly so we have its ID up front. The + // webhook handler will call getCustomerDID(custID), which hits Stripe's + // customer.Get (not search) — that's strongly consistent, so no lag + // concern here. + cust, err := customer.New(&stripe.CustomerParams{ + Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}}, + }) + if err != nil { + t.Fatalf("create customer: %v", err) + } + t.Cleanup(func() { + if _, err := customer.Del(cust.ID, nil); err != nil { + t.Logf("cleanup: delete customer %s: %v", cust.ID, err) + } + }) + + payload := buildEventPayload("evt_test_sub_created", "customer.subscription.created", map[string]any{ + "id": "sub_test_fake_" + cust.ID, + "object": "subscription", + "status": "active", + "customer": cust.ID, + "items": map[string]any{ + "object": "list", + "data": []map[string]any{{ + "id": "si_test_fake", + "object": "subscription_item", + "price": map[string]any{ + "id": env.PriceMonthly, + "object": "price", + "recurring": map[string]any{ + "interval": "month", + }, + }, + }}, + }, + }) + + whReq := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now()) + if err := m.HandleWebhook(whReq); err != nil { + t.Fatalf("HandleWebhook(subscription.created): %v", err) + } + // With managedHolds=[], the dispatched UpdateCrewTierOnAllHolds goroutine + // is a no-op. We've already verified the parse+dispatch path succeeded + // (no error returned and no signature failure). The hold-side push is + // covered by TestWebhookEndpoint in webhook_test.go which boots a real + // appview + harness. +} + +// TestHandleWebhookAllSubscribedEvents iterates billing.SubscribedEvents and +// confirms each one is dispatched to a real handler (not silently dropped to +// the default branch). For each event type we send a minimally-shaped but +// validly-signed payload through HandleWebhook and check that the captured +// slog output contains the handler's distinctive log line — and does NOT +// contain "Ignoring Stripe event", which would mean a switch case was +// removed without updating the SubscribedEvents list. +// +// Most events use a fake customer ID; the subscription.* handlers short- +// circuit on the resulting empty user_did and emit "No user DID found" +// rather than running through to the tier-push branch, which is fine for +// this test — we're verifying dispatch, not downstream side effects. The +// revert-to-free branch is covered separately by +// TestHandleWebhookSubscriptionDeleted. +func TestHandleWebhookAllSubscribedEvents(t *testing.T) { + env := requireStripeEnv(t) + m := newManager(t, env) + cap := newLogCapture(t) + + // Distinctive substring each handler emits. If a switch case is dropped, + // the event hits the default branch ("Ignoring Stripe event") and these + // substrings won't appear in the captured output. + expectedLog := map[string]string{ + billing.EventCheckoutSessionCompleted: "Checkout completed", + billing.EventSubscriptionCreated: "No user DID found", + billing.EventSubscriptionUpdated: "No user DID found", + billing.EventSubscriptionDeleted: "No user DID found", + billing.EventSubscriptionPaused: "No user DID found", + billing.EventSubscriptionResumed: "No user DID found", + billing.EventInvoicePaymentFailed: "Stripe invoice payment failed", + billing.EventChargeDisputeCreated: "Stripe chargeback opened", + } + + for _, eventType := range billing.SubscribedEvents { + t.Run(eventType, func(t *testing.T) { + cap.reset() + + payload := buildEventPayload("evt_test_"+eventType, eventType, payloadFor(eventType, env.PriceMonthly)) + req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now()) + + if err := m.HandleWebhook(req); err != nil { + t.Fatalf("HandleWebhook(%s): %v", eventType, err) + } + + wantSubstr, ok := expectedLog[eventType] + if !ok { + t.Fatalf("test bug: no expected log substring registered for %q. "+ + "Add it to expectedLog when adding a new SubscribedEvents entry.", eventType) + } + if !cap.contains(wantSubstr) { + t.Errorf("event %q did not produce expected log line %q.\nCaptured output:\n%s", + eventType, wantSubstr, cap.String()) + } + if cap.contains("Ignoring Stripe event") { + t.Errorf("event %q hit the default switch branch — it is in SubscribedEvents "+ + "but HandleWebhook has no case for it.\nCaptured output:\n%s", + eventType, cap.String()) + } + }) + } +} + +// TestHandleWebhookSubscriptionDeleted covers the revert-to-free branch +// inside handleSubscriptionChange that the .created test doesn't reach: when +// Stripe sends a canceled subscription, the user must drop to tier rank 0. +// We seed a real Stripe customer (so getCustomerDID returns non-empty and +// the handler proceeds past its early return) and verify the tier-update log +// reports tierName=free / tierRank=0. +func TestHandleWebhookSubscriptionDeleted(t *testing.T) { + env := requireStripeEnv(t) + stripe.Key = env.SecretKey + m := newManager(t, env) + cap := newLogCapture(t) + userDID := uniqueDID(t) + + cust, err := customer.New(&stripe.CustomerParams{ + Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}}, + }) + if err != nil { + t.Fatalf("create customer: %v", err) + } + t.Cleanup(func() { + if _, err := customer.Del(cust.ID, nil); err != nil { + t.Logf("cleanup: delete customer %s: %v", cust.ID, err) + } + }) + + payload := buildEventPayload("evt_test_sub_deleted", billing.EventSubscriptionDeleted, map[string]any{ + "id": "sub_test_deleted_" + cust.ID, + "object": "subscription", + "status": "canceled", + "customer": cust.ID, + // items is intentionally absent: the canceled branch resolves the + // tier from rank 0 directly and never reads items[0].price.id. + }) + req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now()) + if err := m.HandleWebhook(req); err != nil { + t.Fatalf("HandleWebhook(subscription.deleted): %v", err) + } + + if !cap.contains("Pushing tier update to managed holds") { + t.Fatalf("expected tier-update log line, got:\n%s", cap.String()) + } + if !cap.contains(`tierName=free`) { + t.Errorf("expected tierName=free in tier-update log, got:\n%s", cap.String()) + } + if !cap.contains(`tierRank=0`) { + t.Errorf("expected tierRank=0 in tier-update log, got:\n%s", cap.String()) + } +} + +// TestTierResolutionByPriceID is a config-only roundtrip: it makes sure the +// price IDs we feed in actually map back to the configured tier name. A +// mis-pasted env value here makes the rest of the suite mysteriously fail, +// so failing fast on the mapping itself produces better error messages. +func TestTierResolutionByPriceID(t *testing.T) { + env := requireStripeEnv(t) + // Cfg goes through the Manager so the test exercises the same code path + // production does (Manager.New → tier slice → GetTierByPriceID). + cfg := &billing.Config{ + Tiers: []billing.BillingTierConfig{ + {Name: "free"}, + { + Name: env.TierName, + StripePriceMonthly: env.PriceMonthly, + StripePriceYearly: env.PriceYearly, + }, + }, + } + + name, rank := cfg.GetTierByPriceID(env.PriceMonthly) + if name != env.TierName || rank != 1 { + t.Errorf("GetTierByPriceID(monthly) = (%q, %d), want (%q, 1)", name, rank, env.TierName) + } + name, rank = cfg.GetTierByPriceID(env.PriceYearly) + if name != env.TierName || rank != 1 { + t.Errorf("GetTierByPriceID(yearly) = (%q, %d), want (%q, 1)", name, rank, env.TierName) + } + if name, rank := cfg.GetTierByPriceID("price_unknown_xxx"); name != "" || rank != -1 { + t.Errorf("GetTierByPriceID(unknown) = (%q, %d), want (\"\", -1)", name, rank) + } +} + +// --- helpers --------------------------------------------------------------- + +// buildEventPayload assembles a Stripe Event envelope with api_version set +// to the version stripe-go expects, so ConstructEvent doesn't reject it for +// version mismatch. The handler reads event.Type, event.Data.Raw, and not +// much else, so the surrounding fields are minimal. +func buildEventPayload(id, eventType string, dataObject map[string]any) []byte { + envelope := map[string]any{ + "id": id, + "object": "event", + "api_version": stripe.APIVersion, + "type": eventType, + "data": map[string]any{"object": dataObject}, + } + b, err := json.Marshal(envelope) + if err != nil { + // Marshal of a known-good map literal can't fail in practice. + panic("buildEventPayload: " + err.Error()) + } + return b +} + +// payloadFor returns a minimal but well-shaped data.object for the given +// event type — just enough that the handler's json.Unmarshal succeeds and +// the function runs to its first observable log line. priceID is the price +// ID stamped into subscription payloads (unused for non-subscription events). +func payloadFor(eventType, priceID string) map[string]any { + switch eventType { + case "checkout.session.completed": + return map[string]any{ + "id": "cs_test_x", + "object": "checkout.session", + "customer": "cus_fake", + "subscription": "sub_fake", + } + case "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed": + return map[string]any{ + "id": "sub_test_fake", + "object": "subscription", + "status": "active", + "customer": "cus_fake", + "items": map[string]any{ + "object": "list", + "data": []map[string]any{{ + "id": "si_test_fake", + "object": "subscription_item", + "price": map[string]any{ + "id": priceID, + "object": "price", + "recurring": map[string]any{"interval": "month"}, + }, + }}, + }, + } + case "invoice.payment_failed": + return map[string]any{ + "id": "in_test_fake", + "object": "invoice", + "customer": "cus_fake", + "amount_due": 100, + "currency": "usd", + "attempt_count": 1, + } + case "charge.dispute.created": + return map[string]any{ + "id": "dp_test_fake", + "object": "dispute", + "amount": 100, + "currency": "usd", + "reason": "fraudulent", + "status": "warning_needs_response", + // charge is required to be present, even minimally, so the + // handler's dispute.Charge.Customer dereference doesn't NPE. + "charge": map[string]any{ + "id": "ch_test_fake", + "object": "charge", + "customer": "cus_fake", + }, + } + } + // Unknown event type — return an empty object. The test will then notice + // the missing expectedLog entry and fail with a helpful message. + return map[string]any{} +} + +// signedWebhookRequest builds an http.Request with a Stripe-signed body. +// Tests use it for both the happy path (correct secret + recent timestamp) +// and the rejection paths (wrong secret or stale timestamp). +func signedWebhookRequest(t *testing.T, payload []byte, secret string, ts time.Time) *http.Request { + t.Helper() + signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{ + Payload: payload, + Secret: secret, + Timestamp: ts, + }) + req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader(string(signed.Payload))) + req.Header.Set("Stripe-Signature", signed.Header) + req.Header.Set("Content-Type", "application/json") + return req +} + diff --git a/test/stripe-integration/webhook_test.go b/test/stripe-integration/webhook_test.go new file mode 100644 index 0000000..60f75c1 --- /dev/null +++ b/test/stripe-integration/webhook_test.go @@ -0,0 +1,135 @@ +//go:build billing && stripe_integration + +package stripeintegration + +import ( + "net/http" + "strings" + "testing" + "time" + + // Required blank imports for the in-process distribution registry that + // the testharness boots: without these the appview panics with + // "StorageDriver not registered: inmemory". + _ "github.com/distribution/distribution/v3/registry/auth/token" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + + "github.com/stripe/stripe-go/v84/webhook" + + "atcr.io/internal/testharness" + "atcr.io/pkg/billing" +) + +// TestWebhookEndpointAcceptsSignedPayload boots the full in-process stack +// (fake PDS + gofakes3 + hold + appview) with billing wired in, then POSTs +// a Stripe-signed event to /api/stripe/webhook over real HTTP. This is the +// piece the Manager-level tests in manager_test.go can't cover: that the +// route is actually mounted, the chi router forwards the body, and the +// signature secret in cfg.Billing reaches the handler unchanged. +// +// We assert on the HTTP status code rather than any tier-push side effect +// because the harness uses an in-memory hold whose crew table is seeded +// directly — there is no real "managed hold" to push tier updates to, and +// configuring one would couple this test to the holdclient transport layer +// which has its own coverage. +func TestWebhookEndpointAcceptsSignedPayload(t *testing.T) { + env := requireStripeEnv(t) + + billingCfg := billing.Config{ + StripeSecretKey: env.SecretKey, + WebhookSecret: env.WebhookSecret, + Currency: "usd", + SuccessURL: "{base_url}/billing/success", + CancelURL: "{base_url}/billing/cancel", + Tiers: []billing.BillingTierConfig{ + {Name: "free", MaxWebhooks: 1}, + { + Name: env.TierName, + StripePriceMonthly: env.PriceMonthly, + StripePriceYearly: env.PriceYearly, + MaxWebhooks: 10, + WebhookAllTriggers: true, + SupporterBadge: true, + }, + }, + } + + h := testharness.New(t, testharness.WithBilling(billingCfg)) + + // Build and sign a minimal event Stripe might send. + payload := buildEventPayload("evt_test_endpoint", "checkout.session.completed", map[string]any{ + "id": "cs_test_endpoint", + "object": "checkout.session", + "customer": "cus_fake", + "subscription": "sub_fake", + }) + signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{ + Payload: payload, + Secret: env.WebhookSecret, + Timestamp: time.Now(), + }) + + url := h.UIBaseURL + "/api/stripe/webhook" + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(string(signed.Payload))) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Stripe-Signature", signed.Header) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST webhook: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("webhook endpoint returned %d, want 200", resp.StatusCode) + } +} + +// TestWebhookEndpointRejectsBadSignature verifies the same route returns +// a 4xx (not 200) when the signature was generated with a different secret. +// Without this, a misconfigured webhook secret would silently no-op rather +// than failing loudly at the boundary. +func TestWebhookEndpointRejectsBadSignature(t *testing.T) { + env := requireStripeEnv(t) + + billingCfg := billing.Config{ + StripeSecretKey: env.SecretKey, + WebhookSecret: env.WebhookSecret, + Tiers: []billing.BillingTierConfig{ + {Name: "free", MaxWebhooks: 1}, + { + Name: env.TierName, + StripePriceMonthly: env.PriceMonthly, + MaxWebhooks: 10, + }, + }, + } + + h := testharness.New(t, testharness.WithBilling(billingCfg)) + + payload := buildEventPayload("evt_test_bad_sig", "checkout.session.completed", map[string]any{}) + signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{ + Payload: payload, + Secret: "whsec_wrong_secret_for_test", + Timestamp: time.Now(), + }) + + url := h.UIBaseURL + "/api/stripe/webhook" + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(string(signed.Payload))) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Stripe-Signature", signed.Header) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST webhook: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Fatalf("webhook endpoint accepted bad signature (200); want 4xx") + } +}