billing improvements

This commit is contained in:
Evan Jarrett
2026-05-18 22:10:20 -05:00
parent 63909aaca0
commit ecd689a7e1
12 changed files with 1146 additions and 39 deletions
+11 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+7 -4
View File
@@ -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)
+19 -2
View File
@@ -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()
+2 -1
View File
@@ -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
}
+21 -13
View File
@@ -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,
+10 -5
View File
@@ -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"))
+132 -10
View File
@@ -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
+56
View File
@@ -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,
}
+180
View File
@@ -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)
}
+571
View File
@@ -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
}
+135
View File
@@ -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")
}
}