Files
member-console/internal/integrations/stripe/web/webhook.go
T
cgalo5758 efe3f1528d Restrict member surfaces to published products
Add lifecycle_status = 'published' to the public-catalog queries
(plans and add-ons listings) and reject checkout before any Stripe
call unless the product behind the price clears the shared member
gate (published + active + public). The currently-enrolled ladder
rung stays renderable even if its product is later drafted or
retired, fetched directly so members keep seeing what they are on.

Introduce a single evaluateMemberGate definition shared by the
catalog paths and the operator readiness panel so the surfaces
cannot disagree about what is publishable for members.
2026-08-22 12:58:05 -05:00

162 lines
5.5 KiB
Go

// Package web holds Stripe's HTTP handlers (the webhook receiver), moved
// from internal/server per openspec/changes/integration-extraction task 3.3.
package web
import (
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
stripe "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/webhook"
)
// piiKeys lists JSON keys that are stripped from Stripe payloads before storage.
var piiKeys = map[string]struct{}{
"email": {},
"name": {},
"phone": {},
"phone_number": {},
"address": {},
"shipping": {},
"billing_details": {},
"owner": {},
// "card" and "bank_account" are intentionally NOT scrubbed: in Stripe webhook
// payloads these objects contain only safe descriptor fields (brand, last4,
// exp_month, exp_year, routing_number last4) — no raw card numbers or CVCs,
// which Stripe never sends over webhooks. Scrubbing them would prevent
// payment method capture for invoice/payment projection (Phase 1d).
"customer_email": {},
"customer_name": {},
"customer_phone": {},
"customer_address": {},
"receipt_email": {},
"account_holder_name": {},
}
// ScrubPII recursively removes known PII keys from a JSON-decoded value.
func ScrubPII(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(val))
for k, child := range val {
if _, isPII := piiKeys[k]; isPII {
out[k] = "[REDACTED]"
continue
}
out[k] = ScrubPII(child)
}
return out
case []interface{}:
out := make([]interface{}, len(val))
for i, child := range val {
out[i] = ScrubPII(child)
}
return out
default:
return v
}
}
// StripeWebhookHandler handles incoming Stripe webhook events.
type StripeWebhookHandler struct {
DB *sql.DB
WebhookSecret string
Logger *slog.Logger
}
// ServeHTTP verifies the Stripe signature, scrubs PII, and inserts the event
// idempotently into core.webhook_events. It acknowledges with 2xx only once
// the event is durably recorded or identified as a duplicate of an
// already-recorded delivery; if the insert fails, it answers 5xx so Stripe
// redelivers instead of the event being silently dropped.
func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read body (Stripe SDK needs the raw bytes for signature verification)
const maxBodyBytes = 65536
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
if err != nil {
h.Logger.Error("failed to read webhook body", slog.Any("error", err))
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Verify signature with API version mismatch tolerance
// Stripe CLI and API may use different versions; we handle this gracefully
sigHeader := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEventWithOptions(body, sigHeader, h.WebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
h.Logger.Warn("invalid Stripe webhook signature", slog.Any("error", err))
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Log API version mismatches for monitoring
// The SDK has stripe.APIVersion, and event.APIVersion is the webhook's version
if event.APIVersion != stripe.APIVersion {
h.Logger.Warn("Stripe API version mismatch detected",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)),
slog.String("webhook_api_version", event.APIVersion),
slog.String("sdk_api_version", stripe.APIVersion),
slog.String("recommendation", "consider upgrading stripe-go SDK"))
}
// Parse payload for PII scrubbing
var raw interface{}
if err := json.Unmarshal(event.Data.Raw, &raw); err != nil {
h.Logger.Error("failed to parse webhook payload", slog.Any("error", err))
http.Error(w, "Bad payload", http.StatusBadRequest)
return
}
scrubbed := ScrubPII(raw)
scrubbedJSON, err := json.Marshal(scrubbed)
if err != nil {
h.Logger.Error("failed to marshal scrubbed payload", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Idempotent insert. NOT the table's ON CONFLICT: the partitioned
// unique key includes received_at, so ON CONFLICT only collapses
// same-instant duplicates — a Stripe redelivery seconds later would
// insert a second row and reprocess. WHERE NOT EXISTS dedupes on
// (provider, provider_event_id) across time; the remaining
// concurrent-delivery race is the same one ON CONFLICT left open.
_, err = h.DB.ExecContext(r.Context(),
`INSERT INTO core.webhook_events
(provider, provider_event_id, event_type, payload, status)
SELECT $1, $2, $3, $4, 'received'
WHERE NOT EXISTS (
SELECT 1 FROM core.webhook_events
WHERE provider = $1 AND provider_event_id = $2
)`,
"stripe", event.ID, string(event.Type), scrubbedJSON,
)
if err != nil {
h.Logger.Error("failed to insert webhook event",
slog.String("event_id", event.ID),
slog.Any("error", err))
// Nothing was recorded: answer 500 so Stripe redelivers. Returning
// 200 here would silently drop the event, since Stripe only retries
// on non-2xx responses.
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.Logger.Info("webhook event received",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)))
w.WriteHeader(http.StatusOK)
}