1364 lines
44 KiB
Go
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
|
|
}
|