package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "sort" "strconv" "strings" "time" ) var errInvalidStripeSignature = errors.New("invalid stripe signature") type stripeCheckoutRequest struct { PriceID string `json:"priceId"` LineItems []stripeCheckoutLineItem `json:"lineItems"` Mode string `json:"mode"` Scope string `json:"scope"` Tenant string `json:"tenantId"` } type stripeCheckoutResponse struct { SessionID string `json:"sessionId"` CheckoutURL string `json:"checkoutUrl"` } type stripeCheckoutParams struct { CustomerID string CustomerEmail string PriceID string LineItems []stripeCheckoutLineItem Mode string SuccessURL string CancelURL string Metadata map[string]string } type stripeCheckoutLineItem struct { PriceID string `json:"priceId"` Quantity int `json:"quantity"` } type stripeCheckoutSession struct { ID string URL string } type billingPricePolicy struct { PriceID string Mode string EntitlementDurationDays int TrialDays int GraceDays int } type stripeCustomerParams struct { Email string Metadata map[string]string } type stripeCustomer struct { ID string Email string } type stripeWebhookEvent struct { ID string `json:"id"` Type string `json:"type"` Created int64 `json:"created"` Livemode bool `json:"livemode"` Data stripeEventData `json:"data"` } type stripeEventData struct { Object map[string]any `json:"object"` } type billingWebhookProcessResult struct { HTTPStatus int Code string Message string } type stripeEventMetadata struct { UserID string PriceID string PriceIDs []string Scope string TenantID string PlatformAccountID string BillingCustomerID string InitiatedByUserID string InitiatedByTenantID string IsPlatformActor bool } type entitlementLifecyclePatch struct { UserID string Key string Status string Reason string SourceEventID string Scope string TenantID string PlatformAccountID string } type billingWebhookProcessorDeps struct { LookupEvent func(context.Context, string) (Doc, error) CreateEvent func(context.Context, Doc) (Doc, error) PatchEvent func(context.Context, string, Doc) (Doc, error) ResolveEntitlement func(context.Context, string) (string, error) EnsureEntitlement func(context.Context, string, string, stripeWebhookEvent) error UpdateEntitlement func(context.Context, entitlementLifecyclePatch) error ValidateOwnership func(context.Context, stripeEventMetadata) error Now func() time.Time Logf func(string, ...any) } type stripeService interface { EnsureCustomer(context.Context, stripeCustomerParams) (stripeCustomer, error) CreateCheckoutSession(context.Context, stripeCheckoutParams) (stripeCheckoutSession, error) } func (a *app) activeStripeService() stripeService { if a.stripeService != nil { return a.stripeService } return newStripeService(a.cfg) } func (a *app) activeStripeWebhookVerifier() func([]byte, string) (stripeWebhookEvent, error) { if a.stripeWebhookVerifier != nil { return a.stripeWebhookVerifier } return func(payload []byte, signatureHeader string) (stripeWebhookEvent, error) { return verifyStripeWebhookEvent(payload, signatureHeader, a.cfg.StripeWebhookSecret, a.cfg.StripeWebhookTolerance, time.Now()) } } func (a *app) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeError(w, http.StatusBadRequest, "failed to read webhook payload") return } event, err := a.activeStripeWebhookVerifier()(payload, r.Header.Get("Stripe-Signature")) if err != nil { if errors.Is(err, errInvalidStripeSignature) { writeError(w, http.StatusUnauthorized, "invalid stripe signature") return } writeError(w, http.StatusServiceUnavailable, err.Error()) return } result, err := processStripeWebhookEvent(r.Context(), event, a.billingWebhookProcessorDeps()) if err != nil { writeError(w, result.HTTPStatus, result.Message) return } writeJSON(w, result.HTTPStatus, billingWebhookResponse{ Status: result.Code, EventID: event.ID, Message: result.Message, }) } func (a *app) billingWebhookProcessorDeps() billingWebhookProcessorDeps { return billingWebhookProcessorDeps{ LookupEvent: a.lookupBillingEvent, CreateEvent: a.createBillingEvent, PatchEvent: a.patchBillingEvent, ResolveEntitlement: a.resolveBillingEntitlementKey, EnsureEntitlement: a.ensureStripeEntitlement, UpdateEntitlement: a.updateEntitlementLifecycleFromBilling, ValidateOwnership: a.validateBillingEventOwnership, Now: func() time.Time { return time.Now().UTC() }, Logf: log.Printf, } } func processStripeWebhookEvent(ctx context.Context, event stripeWebhookEvent, deps billingWebhookProcessorDeps) (billingWebhookProcessResult, error) { nowFn := deps.Now if nowFn == nil { nowFn = func() time.Time { return time.Now().UTC() } } logf := deps.Logf if logf == nil { logf = log.Printf } if strings.TrimSpace(event.ID) == "" { return billingWebhookProcessResult{HTTPStatus: http.StatusBadRequest, Code: "invalid", Message: "stripe event id is required"}, errors.New("stripe event id is required") } if strings.TrimSpace(event.Type) == "" { return billingWebhookProcessResult{HTTPStatus: http.StatusBadRequest, Code: "invalid", Message: "stripe event type is required"}, errors.New("stripe event type is required") } if existing, err := deps.LookupEvent(ctx, event.ID); err == nil && existing != nil { return billingWebhookProcessResult{HTTPStatus: http.StatusOK, Code: "duplicate", Message: "stripe event already processed"}, nil } else if err != nil && !errors.Is(err, ErrNotFound) { return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: "failed", Message: "failed to load billing event"}, err } eventMetadata := stripeWebhookMetadata(event) receivedAt := nowFn().UnixMilli() if _, err := deps.CreateEvent(ctx, Doc{ "id": strings.TrimSpace(event.ID), "provider": "stripe", "type": strings.TrimSpace(event.Type), "status": "received", "receivedAt": receivedAt, "stripeCreatedAt": event.Created * 1000, "livemode": event.Livemode, "data": event.Data.Object, "scope": eventMetadata.Scope, "tenantId": eventMetadata.TenantID, "platformAccountId": eventMetadata.PlatformAccountID, "userId": eventMetadata.UserID, "priceId": eventMetadata.PriceID, "initiatedByUserId": eventMetadata.InitiatedByUserID, }); err != nil { return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: "failed", Message: "failed to persist billing event"}, err } finalize := func(status string, patch Doc, message string, err error) (billingWebhookProcessResult, error) { if patch == nil { patch = Doc{} } patch["status"] = status patch["processedAt"] = nowFn().UnixMilli() if _, patchErr := deps.PatchEvent(ctx, event.ID, patch); patchErr != nil && err == nil { err = patchErr status = "failed" message = "failed to update billing event" } if err != nil { logf("billing webhook event %s failed: %v", event.ID, err) return billingWebhookProcessResult{HTTPStatus: http.StatusInternalServerError, Code: status, Message: message}, err } if status == "ignored" { logf("billing webhook event %s ignored: %s", event.ID, message) } return billingWebhookProcessResult{HTTPStatus: http.StatusOK, Code: status, Message: message}, nil } if !isSupportedStripeWebhookType(event.Type) { return finalize("ignored", Doc{"reason": "unsupported_event_type"}, "unsupported stripe event type", nil) } metadata := stripeWebhookMetadata(event) if metadata.UserID == "" || len(metadata.PriceIDs) == 0 { return finalize("ignored", Doc{"reason": "missing_metadata"}, "stripe event missing userId or priceId metadata", nil) } if deps.ValidateOwnership != nil { if err := deps.ValidateOwnership(ctx, metadata); err != nil { return finalize("failed", Doc{"reason": "ownership_validation_failed", "userId": metadata.UserID, "priceId": metadata.PriceID}, "billing event ownership validation failed", err) } } lifecycleAction := stripeWebhookLifecycleAction(event.Type) processedPriceIDs := make([]string, 0, len(metadata.PriceIDs)) processedEntitlementKeys := make([]string, 0, len(metadata.PriceIDs)) unmappedPriceIDs := make([]string, 0) for _, priceID := range metadata.PriceIDs { entitlementKey, err := deps.ResolveEntitlement(ctx, priceID) if err != nil { return finalize("failed", Doc{"reason": "entitlement_lookup_failed", "priceId": priceID, "userId": metadata.UserID}, "failed to resolve entitlement for stripe price", err) } if entitlementKey == "" { unmappedPriceIDs = append(unmappedPriceIDs, priceID) continue } switch lifecycleAction { case "grant": if err := deps.EnsureEntitlement(ctx, metadata.UserID, entitlementKey, event); err != nil { return finalize("failed", Doc{"reason": "entitlement_grant_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to grant entitlement", err) } case "grace": if deps.UpdateEntitlement != nil { err := deps.UpdateEntitlement(ctx, entitlementLifecyclePatch{ UserID: metadata.UserID, Key: entitlementKey, Status: entitlementStatusGrace, Reason: "payment_failed", SourceEventID: event.ID, Scope: metadata.Scope, TenantID: metadata.TenantID, PlatformAccountID: metadata.PlatformAccountID, }) if err != nil { return finalize("failed", Doc{"reason": "entitlement_grace_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to apply entitlement grace state", err) } } case "revoke": if deps.UpdateEntitlement != nil { err := deps.UpdateEntitlement(ctx, entitlementLifecyclePatch{ UserID: metadata.UserID, Key: entitlementKey, Status: entitlementStatusRevoked, Reason: "billing_revocation", SourceEventID: event.ID, Scope: metadata.Scope, TenantID: metadata.TenantID, PlatformAccountID: metadata.PlatformAccountID, }) if err != nil { return finalize("failed", Doc{"reason": "entitlement_revoke_failed", "priceId": priceID, "userId": metadata.UserID, "entitlementKey": entitlementKey}, "failed to revoke entitlement", err) } } } processedPriceIDs = append(processedPriceIDs, priceID) processedEntitlementKeys = append(processedEntitlementKeys, entitlementKey) } if len(processedPriceIDs) == 0 { return finalize("ignored", Doc{"reason": "missing_entitlement_mapping", "priceIds": metadata.PriceIDs, "userId": metadata.UserID}, "no entitlement mapping found for stripe price", nil) } return finalize("processed", Doc{ "userId": metadata.UserID, "priceId": processedPriceIDs[0], "priceIds": processedPriceIDs, "entitlementKey": processedEntitlementKeys[0], "entitlementKeys": processedEntitlementKeys, "unmappedPriceIds": unmappedPriceIDs, "scope": metadata.Scope, "tenantId": metadata.TenantID, "platformAccountId": metadata.PlatformAccountID, }, "stripe event processed", nil) } func isSupportedStripeWebhookType(eventType string) bool { switch strings.TrimSpace(eventType) { case "checkout.session.completed", "checkout.session.async_payment_succeeded", "payment_intent.succeeded", "invoice.payment_succeeded", "invoice.payment_failed", "payment_intent.payment_failed", "customer.subscription.deleted", "charge.refunded": return true default: return false } } func stripeWebhookLifecycleAction(eventType string) string { switch strings.TrimSpace(eventType) { case "invoice.payment_failed", "payment_intent.payment_failed": return "grace" case "customer.subscription.deleted", "charge.refunded": return "revoke" default: return "grant" } } func stripeWebhookMetadata(event stripeWebhookEvent) stripeEventMetadata { metadata, _ := event.Data.Object["metadata"].(map[string]any) userID, _ := asString(metadata["userId"]) priceID, _ := asString(metadata["priceId"]) rawPriceIDs, _ := asString(metadata["priceIds"]) scope, _ := asString(metadata["scope"]) tenantID, _ := asString(metadata["tenantId"]) platformAccountID, _ := asString(metadata["platformAccountId"]) billingCustomerID, _ := asString(metadata["billingCustomerId"]) initiatedByUserID, _ := asString(metadata["initiatedByUserId"]) initiatedByTenantID, _ := asString(metadata["initiatedByTenantId"]) isPlatformActorRaw, _ := asString(metadata["isPlatformActor"]) priceIDs := normalizeStripePriceIDs(strings.TrimSpace(priceID), strings.Split(rawPriceIDs, ","), stripeWebhookLineItemPriceIDs(event.Data.Object)) return stripeEventMetadata{ UserID: strings.TrimSpace(userID), PriceID: firstStripePriceID(priceIDs), PriceIDs: priceIDs, Scope: strings.TrimSpace(strings.ToLower(scope)), TenantID: strings.TrimSpace(tenantID), PlatformAccountID: strings.TrimSpace(platformAccountID), BillingCustomerID: strings.TrimSpace(billingCustomerID), InitiatedByUserID: strings.TrimSpace(initiatedByUserID), InitiatedByTenantID: strings.TrimSpace(initiatedByTenantID), IsPlatformActor: strings.EqualFold(strings.TrimSpace(isPlatformActorRaw), "true"), } } func verifyStripeWebhookEvent(payload []byte, signatureHeader, secret string, tolerance time.Duration, now time.Time) (stripeWebhookEvent, error) { if strings.TrimSpace(secret) == "" { return stripeWebhookEvent{}, errors.New("stripe webhook secret not configured") } timestamp, signatures, err := parseStripeSignatureHeader(signatureHeader) if err != nil { return stripeWebhookEvent{}, errInvalidStripeSignature } if tolerance > 0 && now.Sub(time.Unix(timestamp, 0)) > tolerance { return stripeWebhookEvent{}, errInvalidStripeSignature } mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(strconv.FormatInt(timestamp, 10))) _, _ = mac.Write([]byte(".")) _, _ = mac.Write(payload) expected := fmt.Sprintf("%x", mac.Sum(nil)) valid := false for _, signature := range signatures { if hmac.Equal([]byte(signature), []byte(expected)) { valid = true break } } if !valid { return stripeWebhookEvent{}, errInvalidStripeSignature } var event stripeWebhookEvent if err := json.Unmarshal(payload, &event); err != nil { return stripeWebhookEvent{}, errInvalidStripeSignature } return event, nil } func parseStripeSignatureHeader(header string) (int64, []string, error) { parts := strings.Split(header, ",") var timestamp int64 hasTimestamp := false signatures := []string{} for _, part := range parts { segment := strings.TrimSpace(part) key, value, ok := strings.Cut(segment, "=") if !ok { continue } switch strings.TrimSpace(key) { case "t": parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return 0, nil, errInvalidStripeSignature } timestamp = parsed hasTimestamp = true case "v1": if signature := strings.TrimSpace(value); signature != "" { signatures = append(signatures, signature) } } } if !hasTimestamp || len(signatures) == 0 { return 0, nil, errInvalidStripeSignature } sort.Strings(signatures) return timestamp, signatures, nil } func (a *app) lookupBillingEvent(ctx context.Context, id string) (Doc, error) { if a.db == nil { return nil, errors.New("database not configured") } _ = ctx return a.getByID("billing_events", strings.TrimSpace(id)) } func (a *app) createBillingEvent(ctx context.Context, doc Doc) (Doc, error) { if a.db == nil { return nil, errors.New("database not configured") } _ = ctx return a.create("billing_events", doc) } func (a *app) patchBillingEvent(ctx context.Context, id string, patch Doc) (Doc, error) { if a.db == nil { return nil, errors.New("database not configured") } _ = ctx return a.patch("billing_events", strings.TrimSpace(id), patch) } func (a *app) resolveBillingEntitlementKey(ctx context.Context, priceID string) (string, error) { if a.db == nil { return "", errors.New("database not configured") } priceID = strings.TrimSpace(priceID) if priceID == "" { return "", nil } cohorts, err := a.list("live_course_cohorts", Doc{"priceId": priceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return "", err } if len(cohorts) > 0 { return resolveEntitlementKey(nil, cohorts[0]), nil } courses, err := a.list("live_courses", Doc{"priceId": priceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return "", err } if len(courses) > 0 { return resolveEntitlementKey(courses[0], nil), nil } return "", nil } func (a *app) resolveBillingPricePolicy(ctx context.Context, priceID, mode string) (billingPricePolicy, error) { _ = ctx policy := billingPricePolicy{ PriceID: strings.TrimSpace(priceID), Mode: normalizeStripeCheckoutMode(mode), EntitlementDurationDays: 3650, TrialDays: a.cfg.DefaultTrialDays, GraceDays: 7, } if policy.Mode == "subscription" { policy.EntitlementDurationDays = 32 } if a.db == nil || policy.PriceID == "" { return policy, nil } docs, err := a.list("billing_price_policies", Doc{"priceId": policy.PriceID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return policy, err } if len(docs) == 0 { return policy, nil } doc := docs[0] if overrideMode := strings.TrimSpace(anyToString(doc["mode"])); overrideMode != "" { policy.Mode = normalizeStripeCheckoutMode(overrideMode) } if duration := intNumber(doc["durationDays"]); duration > 0 { policy.EntitlementDurationDays = duration } if trialDays := intNumber(doc["trialDays"]); trialDays > 0 { policy.TrialDays = trialDays } if graceDays := intNumber(doc["graceDays"]); graceDays > 0 { policy.GraceDays = graceDays } return policy, nil } func (a *app) ensureStripeEntitlement(ctx context.Context, userID, entitlementKey string, event stripeWebhookEvent) error { if a.db == nil { return errors.New("database not configured") } _ = ctx active, err := a.hasActiveEntitlement(userID, entitlementKey, time.Now().UTC()) if err != nil { return err } if active { return nil } meta := stripeWebhookMetadata(event) policy, err := a.resolveBillingPricePolicy(ctx, meta.PriceID, stringValue(event.Data.Object["mode"])) if err != nil { return err } now := time.Now().UTC() doc, err := prepareEntitlementPayload(Doc{ "userId": userID, "key": entitlementKey, "status": entitlementStatusActive, "source": "stripe_" + policy.Mode, "startsAt": now.UnixMilli(), "endsAt": now.Add(time.Duration(policy.EntitlementDurationDays) * 24 * time.Hour).UnixMilli(), "scope": meta.Scope, "tenantId": meta.TenantID, "platformAccountId": meta.PlatformAccountID, "sourceEventId": event.ID, "initiatedByUserId": meta.InitiatedByUserID, "reason": "stripe_" + strings.ReplaceAll(event.Type, ".", "_"), "metadata": Doc{ "stripeEventId": event.ID, "stripeEventType": event.Type, "priceId": meta.PriceID, "checkoutSessionId": stringValue(event.Data.Object["id"]), "scope": meta.Scope, "tenantId": meta.TenantID, "platformAccountId": meta.PlatformAccountID, }, }, false) if err != nil { return err } _, err = a.create("entitlements", doc) return err } func (a *app) updateEntitlementLifecycleFromBilling(ctx context.Context, patch entitlementLifecyclePatch) error { if a.db == nil { return errors.New("database not configured") } filter := Doc{ "userId": strings.TrimSpace(patch.UserID), "key": strings.TrimSpace(patch.Key), } if strings.TrimSpace(patch.Scope) != "" { filter["scope"] = strings.TrimSpace(patch.Scope) } if strings.TrimSpace(patch.TenantID) != "" { filter["tenantId"] = strings.TrimSpace(patch.TenantID) } docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return err } if len(docs) == 0 { return nil } updatePatch := Doc{ "status": strings.TrimSpace(strings.ToLower(patch.Status)), "reason": strings.TrimSpace(patch.Reason), "sourceEventId": strings.TrimSpace(patch.SourceEventID), "scope": strings.TrimSpace(patch.Scope), "tenantId": strings.TrimSpace(patch.TenantID), "platformAccountId": strings.TrimSpace(patch.PlatformAccountID), "source": "stripe_lifecycle", } now := time.Now().UTC().UnixMilli() if updatePatch["status"] == entitlementStatusRevoked { updatePatch["revokedAt"] = now } else if updatePatch["status"] == entitlementStatusGrace { updatePatch["graceAt"] = now } _, err = a.patch("entitlements", stringValue(docs[0]["id"]), updatePatch) return err } func (a *app) validateBillingEventOwnership(ctx context.Context, metadata stripeEventMetadata) error { if a.db == nil { return errors.New("database not configured") } _ = ctx scope := strings.TrimSpace(metadata.Scope) if scope == "" { scope = entitlementScopeUser } switch scope { case entitlementScopeTenant: if strings.TrimSpace(metadata.TenantID) == "" { return errors.New("tenant scope billing event missing tenantId") } if !metadata.IsPlatformActor && strings.TrimSpace(metadata.InitiatedByTenantID) != "" && strings.TrimSpace(metadata.InitiatedByTenantID) != strings.TrimSpace(metadata.TenantID) { return errors.New("cross-tenant billing event blocked") } case entitlementScopePlatform: if strings.TrimSpace(metadata.PlatformAccountID) == "" { return errors.New("platform scope billing event missing platformAccountId") } } if strings.TrimSpace(metadata.BillingCustomerID) == "" { return nil } customer, err := a.getByID("billing_customers", strings.TrimSpace(metadata.BillingCustomerID)) if err != nil { if errors.Is(err, ErrNotFound) { return errors.New("billing customer not found for event") } return err } if customerUserID := strings.TrimSpace(anyToString(customer["userId"])); customerUserID != "" && customerUserID != strings.TrimSpace(metadata.UserID) { return errors.New("billing customer user mismatch") } if customerScope := strings.TrimSpace(anyToString(customer["scope"])); customerScope != "" && customerScope != scope { return errors.New("billing customer scope mismatch") } if scope == entitlementScopeTenant { customerTenantID := strings.TrimSpace(anyToString(customer["tenantId"])) if customerTenantID != "" && customerTenantID != strings.TrimSpace(metadata.TenantID) { return errors.New("billing customer tenant mismatch") } } return nil } func (a *app) billingComplianceReport(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) filter := Doc{} if enforceTenant { filter["tenantId"] = tenantID } entitlements, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load entitlements for compliance report") return } events, err := a.list("billing_events", filter, Sort{{Key: "receivedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load billing events for compliance report") return } statusCounts := map[string]int{} scopeCounts := map[string]int{} for _, doc := range entitlements { status := strings.TrimSpace(strings.ToLower(anyToString(doc["status"]))) if status == "" { status = "unknown" } statusCounts[status]++ scope := strings.TrimSpace(strings.ToLower(anyToString(doc["scope"]))) if scope == "" { scope = "unknown" } scopeCounts[scope]++ } eventStatusCounts := map[string]int{} for _, doc := range events { status := strings.TrimSpace(strings.ToLower(anyToString(doc["status"]))) if status == "" { status = "unknown" } eventStatusCounts[status]++ } writeJSON(w, http.StatusOK, Doc{ "tenantId": tenantID, "tenantScoped": enforceTenant, "entitlementCount": len(entitlements), "billingEventCount": len(events), "entitlementByStatus": statusCounts, "entitlementByScope": scopeCounts, "billingEventByStatus": eventStatusCounts, }) } func (a *app) backfillBillingComplianceData(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) actorID := "" if claims != nil { actorID = strings.TrimSpace(claims.Sub) } backfilled := map[string]int{"entitlements": 0, "billing_customers": 0, "billing_events": 0} for _, collection := range []string{"entitlements", "billing_customers", "billing_events"} { docs, err := a.list(collection, Doc{}, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list records for backfill") return } for _, doc := range docs { patch := Doc{} scope := strings.TrimSpace(anyToString(doc["scope"])) tenantID := strings.TrimSpace(anyToString(doc["tenantId"])) if scope == "" { if tenantID != "" { scope = entitlementScopeTenant } else { scope = entitlementScopeUser } patch["scope"] = scope } if scope == entitlementScopePlatform && strings.TrimSpace(anyToString(doc["platformAccountId"])) == "" { patch["platformAccountId"] = "platform" } if actorID != "" { patch["lastUpdatedByUserId"] = actorID } if len(patch) == 0 { continue } if _, err := a.patch(collection, stringValue(doc["id"]), patch); err != nil { writeError(w, http.StatusInternalServerError, "failed to patch compliance backfill record") return } backfilled[collection]++ } } writeJSON(w, http.StatusOK, Doc{"backfilled": backfilled}) } func (a *app) applyBillingRetentionPolicy(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } now := time.Now().UTC() billingCutoff := now.Add(-time.Duration(a.cfg.BillingEventRetentionDays) * 24 * time.Hour).UnixMilli() entitlementCutoff := now.Add(-time.Duration(a.cfg.EntitlementHistoryRetentionDays) * 24 * time.Hour).UnixMilli() ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() billingCollection, err := a.resolveCollection("billing_events") if err != nil { writeError(w, http.StatusInternalServerError, "failed to resolve billing events collection") return } entitlementCollection, err := a.resolveCollection("entitlements") if err != nil { writeError(w, http.StatusInternalServerError, "failed to resolve entitlements collection") return } billingResult, err := billingCollection.UpdateMany(ctx, Doc{ "receivedAt": Doc{"$lt": billingCutoff}, "archivedAt": Doc{"$exists": false}, }, Doc{"$set": Doc{"archivedAt": now.UnixMilli(), "archivedReason": "retention_policy"}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to archive billing events") return } entitlementResult, err := entitlementCollection.UpdateMany(ctx, Doc{ "updatedAt": Doc{"$lt": entitlementCutoff}, "status": Doc{"$in": List{entitlementStatusRevoked, entitlementStatusExpired}}, "archivedAt": Doc{"$exists": false}, }, Doc{"$set": Doc{"archivedAt": now.UnixMilli(), "archivedReason": "retention_policy"}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to archive entitlements") return } writeJSON(w, http.StatusOK, Doc{ "billingEventsArchived": billingResult.ModifiedCount, "entitlementsArchived": entitlementResult.ModifiedCount, "billingCutoff": billingCutoff, "entitlementCutoff": entitlementCutoff, }) } func (a *app) createCheckoutSession(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil || strings.TrimSpace(claims.Sub) == "" { writeError(w, http.StatusUnauthorized, "no authenticated user") return } var payload stripeCheckoutRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } lineItems, err := normalizeStripeCheckoutLineItems(payload.PriceID, payload.LineItems) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } for _, lineItem := range lineItems { if !isAllowedStripePriceID(a.cfg.StripeAllowedPriceIDs, lineItem.PriceID) { writeError(w, http.StatusForbidden, "priceId is not allowed") return } } if len(lineItems) == 0 { writeError(w, http.StatusBadRequest, "priceId is required") return } mode := normalizeStripeCheckoutMode(payload.Mode) customerEmail := strings.TrimSpace(claims.Email) if customerEmail == "" { writeError(w, http.StatusBadRequest, "authenticated user email is required for checkout") return } scope, err := resolveCheckoutScope(claims, payload.Scope, payload.Tenant) if err != nil { switch { case errors.Is(err, errCheckoutScopeForbidden): writeError(w, http.StatusForbidden, err.Error()) default: writeError(w, http.StatusBadRequest, err.Error()) } return } service := a.activeStripeService() var customerDoc Doc if a.billingCustomerEnsurer != nil { customerDoc, err = a.billingCustomerEnsurer(scope.CustomerKey, customerEmail) } else { customerDoc, err = a.ensureBillingCustomer(r.Context(), scope, claims, customerEmail, service) } if err != nil { writeError(w, http.StatusInternalServerError, "failed to prepare billing customer") return } metadata := map[string]string{ "billingCustomerId": stringValue(customerDoc["id"]), "priceId": lineItems[0].PriceID, "priceIds": strings.Join(checkoutLineItemPriceIDs(lineItems), ","), "scope": scope.Scope, "isPlatformActor": strconv.FormatBool(hasAnyRole(claims, "admin", "platform_owner", "platform_admin")), } if scope.Scope == "user" { metadata["userId"] = strings.TrimSpace(claims.Sub) } if scope.TenantID != "" { metadata["tenantId"] = scope.TenantID } if initiatedByTenantID := strings.TrimSpace(resolveClaimsTenantID(claims)); initiatedByTenantID != "" { metadata["initiatedByTenantId"] = initiatedByTenantID } if scope.PlatformAccountID != "" { metadata["platformAccountId"] = scope.PlatformAccountID } if actorID := strings.TrimSpace(claims.Sub); actorID != "" { metadata["initiatedByUserId"] = actorID } session, err := service.CreateCheckoutSession(r.Context(), stripeCheckoutParams{ CustomerID: stringValue(customerDoc["stripeCustomerId"]), CustomerEmail: customerEmail, PriceID: lineItems[0].PriceID, LineItems: lineItems, Mode: mode, SuccessURL: a.cfg.StripeCheckoutSuccessURL, CancelURL: a.cfg.StripeCheckoutCancelURL, Metadata: metadata, }) if err != nil { writeError(w, http.StatusBadGateway, "failed to create checkout session") return } writeJSON(w, http.StatusCreated, stripeCheckoutResponse{ SessionID: session.ID, CheckoutURL: session.URL, }) } var errCheckoutScopeForbidden = errors.New("checkout scope requires platform admin role") type checkoutScope struct { Scope string TenantID string PlatformAccountID string CustomerKey string } func resolveCheckoutScope(claims *accessClaims, requestedScope, requestedTenantID string) (checkoutScope, error) { if claims == nil { return checkoutScope{}, errors.New("no authenticated user") } tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) scope := strings.TrimSpace(strings.ToLower(requestedScope)) isPlatformRole := hasAnyRole(claims, "admin", "platform_owner", "platform_admin") if scope == "" { if tenantID != "" { scope = "tenant" } else { scope = "user" } } switch scope { case "user": return checkoutScope{Scope: "user", CustomerKey: strings.TrimSpace(claims.Sub)}, nil case "tenant": targetTenantID := strings.TrimSpace(requestedTenantID) if targetTenantID == "" { targetTenantID = tenantID } if targetTenantID == "" { return checkoutScope{}, errors.New("tenantId is required for tenant scope checkout") } if !isPlatformRole && tenantID != targetTenantID { return checkoutScope{}, errCheckoutScopeForbidden } return checkoutScope{ Scope: "tenant", TenantID: targetTenantID, PlatformAccountID: "", CustomerKey: "tenant:" + targetTenantID, }, nil case "platform": if !isPlatformRole { return checkoutScope{}, errCheckoutScopeForbidden } return checkoutScope{Scope: "platform", CustomerKey: "platform", PlatformAccountID: "platform"}, nil default: return checkoutScope{}, errors.New("scope must be one of: user, tenant, platform") } } func (a *app) ensureBillingCustomer(ctx context.Context, scope checkoutScope, claims *accessClaims, email string, service stripeService) (Doc, error) { if a.db == nil { return nil, errors.New("database not configured") } if service == nil { return nil, errors.New("stripe service not configured") } userID := strings.TrimSpace(scope.CustomerKey) tenantID := strings.TrimSpace(scope.TenantID) initiatedByUserID := "" if claims != nil { initiatedByUserID = strings.TrimSpace(claims.Sub) } docs, err := a.list("billing_customers", Doc{"userId": strings.TrimSpace(userID)}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return nil, err } ensureStripeCustomer := func(existing Doc) (string, error) { stripeCustomerID := stringValue(existing["stripeCustomerId"]) if stripeCustomerID != "" { return stripeCustomerID, nil } customer, err := service.EnsureCustomer(ctx, stripeCustomerParams{ Email: strings.TrimSpace(email), Metadata: map[string]string{ "userId": strings.TrimSpace(userID), }, }) if err != nil { return "", err } return customer.ID, nil } if len(docs) > 0 { doc := docs[0] patch := Doc{} currentEmail := stringValue(doc["email"]) if strings.TrimSpace(email) != "" && currentEmail != strings.TrimSpace(email) { patch["email"] = strings.TrimSpace(email) } if stringValue(doc["scope"]) != scope.Scope { patch["scope"] = scope.Scope } if stringValue(doc["tenantId"]) != tenantID { patch["tenantId"] = tenantID } if stringValue(doc["platformAccountId"]) != scope.PlatformAccountID { patch["platformAccountId"] = strings.TrimSpace(scope.PlatformAccountID) } if initiatedByUserID != "" { patch["lastInitiatedByUserId"] = initiatedByUserID } stripeCustomerID, err := ensureStripeCustomer(doc) if err != nil { return nil, err } if stringValue(doc["stripeCustomerId"]) != stripeCustomerID { patch["stripeCustomerId"] = stripeCustomerID } if len(patch) == 0 { return doc, nil } return a.patch("billing_customers", stringValue(doc["id"]), patch) } customer, err := service.EnsureCustomer(ctx, stripeCustomerParams{ Email: strings.TrimSpace(email), Metadata: map[string]string{ "userId": strings.TrimSpace(userID), "scope": scope.Scope, "tenantId": tenantID, "platformAccountId": strings.TrimSpace(scope.PlatformAccountID), }, }) if err != nil { return nil, err } return a.create("billing_customers", Doc{ "userId": strings.TrimSpace(userID), "email": strings.TrimSpace(email), "stripeCustomerId": strings.TrimSpace(customer.ID), "scope": scope.Scope, "tenantId": tenantID, "platformAccountId": strings.TrimSpace(scope.PlatformAccountID), "lastInitiatedByUserId": initiatedByUserID, }) } func isAllowedStripePriceID(allowed []string, priceID string) bool { if strings.TrimSpace(priceID) == "" { return false } for _, candidate := range allowed { if strings.TrimSpace(candidate) == strings.TrimSpace(priceID) { return true } } return false } func normalizeStripeCheckoutLineItems(legacyPriceID string, raw []stripeCheckoutLineItem) ([]stripeCheckoutLineItem, error) { if len(raw) == 0 { priceID := strings.TrimSpace(legacyPriceID) if priceID == "" { return nil, nil } return []stripeCheckoutLineItem{{PriceID: priceID, Quantity: 1}}, nil } items := make([]stripeCheckoutLineItem, 0, len(raw)) for _, candidate := range raw { priceID := strings.TrimSpace(candidate.PriceID) if priceID == "" { return nil, errors.New("lineItems[].priceId is required") } quantity := candidate.Quantity if quantity <= 0 { return nil, errors.New("lineItems[].quantity must be greater than 0") } items = append(items, stripeCheckoutLineItem{ PriceID: priceID, Quantity: quantity, }) } return items, nil } func checkoutLineItemPriceIDs(lineItems []stripeCheckoutLineItem) []string { priceIDs := make([]string, 0, len(lineItems)) for _, lineItem := range lineItems { priceID := strings.TrimSpace(lineItem.PriceID) if priceID == "" { continue } priceIDs = append(priceIDs, priceID) } return priceIDs } func firstStripePriceID(priceIDs []string) string { if len(priceIDs) == 0 { return "" } return strings.TrimSpace(priceIDs[0]) } func normalizeStripePriceIDs(primary string, metadataValues []string, lineItemValues []string) []string { seen := map[string]struct{}{} priceIDs := make([]string, 0, len(metadataValues)+len(lineItemValues)+1) add := func(value string) { priceID := strings.TrimSpace(value) if priceID == "" { return } if _, ok := seen[priceID]; ok { return } seen[priceID] = struct{}{} priceIDs = append(priceIDs, priceID) } add(primary) for _, value := range metadataValues { add(value) } for _, value := range lineItemValues { add(value) } return priceIDs } func stripeWebhookLineItemPriceIDs(eventObject map[string]any) []string { lines, _ := eventObject["lines"].(map[string]any) data, _ := lines["data"].([]any) if len(data) == 0 { return nil } priceIDs := make([]string, 0, len(data)) for _, entry := range data { line, _ := entry.(map[string]any) priceMap, _ := line["price"].(map[string]any) priceID, _ := asString(priceMap["id"]) if strings.TrimSpace(priceID) == "" { planMap, _ := line["plan"].(map[string]any) priceID, _ = asString(planMap["id"]) } priceIDs = append(priceIDs, strings.TrimSpace(priceID)) } return priceIDs } func normalizeStripeCheckoutMode(raw string) string { normalized := strings.TrimSpace(strings.ToLower(raw)) if normalized == "subscription" { return "subscription" } return "payment" } func stringValue(raw any) string { value, _ := asString(raw) return value } func intNumber(raw any) int { switch value := raw.(type) { case int: return value case int32: return int(value) case int64: return int(value) case float64: return int(value) case float32: return int(value) default: return 0 } } type stripeAPIService struct { secretKey string apiBaseURL string httpClient notificationHTTPClient } func newStripeService(cfg config) stripeService { secretKey := strings.TrimSpace(cfg.StripeSecretKey) if secretKey == "" { return noopStripeService{} } baseURL := strings.TrimSpace(cfg.StripeAPIBaseURL) if baseURL == "" { baseURL = "https://api.stripe.com" } return stripeAPIService{ secretKey: secretKey, apiBaseURL: strings.TrimRight(baseURL, "/"), httpClient: http.DefaultClient, } } type noopStripeService struct{} func (noopStripeService) EnsureCustomer(ctx context.Context, params stripeCustomerParams) (stripeCustomer, error) { _ = ctx identifier := strings.ReplaceAll(strings.TrimSpace(params.Email), "@", "_at_") if identifier == "" { identifier = "anonymous" } return stripeCustomer{ID: "noop-customer-" + identifier, Email: strings.TrimSpace(params.Email)}, nil } func (noopStripeService) CreateCheckoutSession(ctx context.Context, params stripeCheckoutParams) (stripeCheckoutSession, error) { _ = ctx return stripeCheckoutSession{ ID: "noop-checkout-session", URL: params.SuccessURL, }, nil } func (s stripeAPIService) EnsureCustomer(ctx context.Context, params stripeCustomerParams) (stripeCustomer, error) { form := url.Values{} form.Set("email", strings.TrimSpace(params.Email)) for key, value := range params.Metadata { if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { continue } form.Set(fmt.Sprintf("metadata[%s]", key), value) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBaseURL+"/v1/customers", strings.NewReader(form.Encode())) if err != nil { return stripeCustomer{}, fmt.Errorf("create stripe customer request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.secretKey) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := s.httpClient if client == nil { client = http.DefaultClient } res, err := client.Do(req) if err != nil { return stripeCustomer{}, fmt.Errorf("stripe customer request failed: %w", err) } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode > 299 { raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) return stripeCustomer{}, fmt.Errorf("stripe customer failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) } var payload struct { ID string `json:"id"` Email string `json:"email"` } if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return stripeCustomer{}, fmt.Errorf("decode stripe customer response: %w", err) } if strings.TrimSpace(payload.ID) == "" { return stripeCustomer{}, errors.New("stripe customer response missing id") } return stripeCustomer{ID: strings.TrimSpace(payload.ID), Email: strings.TrimSpace(payload.Email)}, nil } func (s stripeAPIService) CreateCheckoutSession(ctx context.Context, params stripeCheckoutParams) (stripeCheckoutSession, error) { form := url.Values{} form.Set("mode", normalizeStripeCheckoutMode(params.Mode)) form.Set("success_url", params.SuccessURL) form.Set("cancel_url", params.CancelURL) if strings.TrimSpace(params.CustomerID) != "" { form.Set("customer", strings.TrimSpace(params.CustomerID)) } else { form.Set("customer_email", params.CustomerEmail) } lineItems := params.LineItems if len(lineItems) == 0 && strings.TrimSpace(params.PriceID) != "" { lineItems = []stripeCheckoutLineItem{{PriceID: strings.TrimSpace(params.PriceID), Quantity: 1}} } for idx, lineItem := range lineItems { priceID := strings.TrimSpace(lineItem.PriceID) if priceID == "" { continue } quantity := lineItem.Quantity if quantity <= 0 { quantity = 1 } form.Set(fmt.Sprintf("line_items[%d][price]", idx), priceID) form.Set(fmt.Sprintf("line_items[%d][quantity]", idx), strconv.Itoa(quantity)) } for key, value := range params.Metadata { if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { continue } form.Set(fmt.Sprintf("metadata[%s]", key), value) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBaseURL+"/v1/checkout/sessions", strings.NewReader(form.Encode())) if err != nil { return stripeCheckoutSession{}, fmt.Errorf("create stripe request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.secretKey) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := s.httpClient if client == nil { client = http.DefaultClient } res, err := client.Do(req) if err != nil { return stripeCheckoutSession{}, fmt.Errorf("stripe request failed: %w", err) } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode > 299 { raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) return stripeCheckoutSession{}, fmt.Errorf("stripe checkout failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) } var payload struct { ID string `json:"id"` URL string `json:"url"` } if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return stripeCheckoutSession{}, fmt.Errorf("decode stripe response: %w", err) } if strings.TrimSpace(payload.ID) == "" || strings.TrimSpace(payload.URL) == "" { return stripeCheckoutSession{}, errors.New("stripe checkout response missing id or url") } return stripeCheckoutSession{ID: strings.TrimSpace(payload.ID), URL: strings.TrimSpace(payload.URL)}, nil }