mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-28 20:06:02 +00:00
billing improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user