1364 lines
44 KiB
Go

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
}