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.
162 lines
5.5 KiB
Go
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)
|
|
}
|