Pulse/internal/api/subscription_entitlements.go

418 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// EntitlementPayload is the normalized entitlement response for frontend consumption.
// Frontend should use this instead of inferring capabilities from tier names.
type EntitlementPayload = entitlementPayloadModel
// RuntimeCapabilitiesPayload is the canonical non-commercial runtime capability
// response for feature gating and operational limit checks.
type RuntimeCapabilitiesPayload = runtimeCapabilitiesPayloadModel
// CommercialPosturePayload is the canonical non-billing commercial response
// for upgrade/trial posture and monitored-system migration guidance.
type CommercialPosturePayload = commercialPosturePayloadModel
// LimitStatus represents a quantitative limit with current usage state.
type LimitStatus = limitStatusModel
// UpgradeReason provides context for why a user should upgrade.
type UpgradeReason = upgradeReasonModel
func (h *LicenseHandlers) buildCommercialEntitlementPayload(
ctx context.Context,
) (EntitlementPayload, error) {
svc, _, err := h.getTenantComponents(ctx)
if err != nil {
return EntitlementPayload{}, err
}
status := svc.Status()
usage := h.entitlementUsageSnapshot(ctx)
trialEndsAtUnix := trialEndsAtUnixFromService(svc)
// Onboarding overflow: +1 agent for 14 days on free tier.
overflowGrantedAt := h.ensureOnboardingOverflow(ctx, status.Tier)
now := time.Now()
if bonus := overflowBonusFromLicensing(status.Tier, overflowGrantedAt, now); bonus > 0 {
status.MaxMonitoredSystems += bonus
}
payload := buildEntitlementPayloadWithUsage(status, svc.SubscriptionState(), usage, trialEndsAtUnix)
// Surface overflow days remaining for frontend messaging.
if days := overflowDaysRemainingFromLicensing(status.Tier, overflowGrantedAt, now); days > 0 {
payload.OverflowDaysRemaining = &days
}
if eval := svc.Evaluator(); eval != nil {
if pv := strings.TrimSpace(eval.PlanVersion()); pv != "" {
payload.PlanVersion = pv
}
}
existing := h.billingStateForContext(ctx)
if existing != nil {
payload.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(existing.CommercialMigration)
}
payload.TrialEligible, payload.TrialEligibilityReason = h.trialStartEligibility(ctx, svc, existing)
payload.HostedMode = h != nil && h.hostedMode
return payload, nil
}
// HandleEntitlements returns the normalized entitlement payload for the current tenant.
// This is the commercial entitlement endpoint for billing, trial, and upgrade presentation.
func (h *LicenseHandlers) HandleEntitlements(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
payload, err := h.buildCommercialEntitlementPayload(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Internal server error", nil)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}
// HandleCommercialPosture returns the canonical non-billing commercial posture payload.
func (h *LicenseHandlers) HandleCommercialPosture(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
payload, err := h.buildCommercialEntitlementPayload(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Internal server error", nil)
return
}
posture := commercialPosturePayloadFromEntitlementPayload(payload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(posture)
}
// HandleRuntimeCapabilities returns the canonical non-commercial runtime capability payload.
func (h *LicenseHandlers) HandleRuntimeCapabilities(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
svc, _, err := h.getTenantComponents(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Internal server error", nil)
return
}
status := svc.Status()
usage := h.entitlementUsageSnapshot(r.Context())
overflowGrantedAt := h.ensureOnboardingOverflow(r.Context(), status.Tier)
now := time.Now()
if bonus := overflowBonusFromLicensing(status.Tier, overflowGrantedAt, now); bonus > 0 {
status.MaxMonitoredSystems += bonus
}
payload := buildRuntimeCapabilitiesPayloadWithUsage(status, svc.SubscriptionState(), usage)
payload.HostedMode = h != nil && h.hostedMode
if h != nil && h.cfg != nil && h.cfg.DemoMode {
payload = sanitizeRuntimeCapabilitiesPayloadForPublicDemo(payload)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}
func trialEndsAtUnixFromService(svc *licenseService) *int64 {
if svc == nil {
return nil
}
eval := svc.Evaluator()
if eval == nil {
return nil
}
return eval.TrialEndsAt()
}
type entitlementUsageSnapshot = entitlementUsageSnapshotModel
// entitlementUsageSnapshot returns best-effort runtime usage counts for limits.
// The monitored-system cap applies to canonical top-level monitored systems
// regardless of collection path.
func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlementUsageSnapshot {
usage := entitlementUsageSnapshot{}
if h == nil {
return usage
}
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
// Count canonical top-level monitored systems from monitor state.
var monitorResolved bool
if h.mtMonitor != nil {
if monitor, err := h.mtMonitor.GetMonitor(orgID); err == nil && monitor != nil {
state := monitor.MonitoredSystemUsage()
if state.Available {
usage.MonitoredSystems = int64(state.Count)
usage.MonitoredSystemsAvailable = true
usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState)
monitorResolved = true
} else if usage.MonitoredSystemsUnavailableReason == "" {
usage.MonitoredSystemsUnavailableReason = state.UnavailableReason
}
}
}
if !monitorResolved && orgID == "default" && h.monitor != nil {
state := h.monitor.MonitoredSystemUsage()
if state.Available {
usage.MonitoredSystems = int64(state.Count)
usage.MonitoredSystemsAvailable = true
usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState)
} else if usage.MonitoredSystemsUnavailableReason == "" {
usage.MonitoredSystemsUnavailableReason = state.UnavailableReason
}
}
// Guest metadata for guest limit tracking.
if h.mtPersistence != nil {
if persistence, err := h.mtPersistence.GetPersistence(orgID); err == nil && persistence != nil {
if guestStore := persistence.GetGuestMetadataStore(); guestStore != nil {
usage.Guests = int64(len(guestStore.GetAll()))
}
}
}
return usage
}
// buildEntitlementPayload constructs the normalized payload from LicenseStatus.
// This provides backward compatibility before the evaluator is wired in.
func buildEntitlementPayload(status *licenseStatus, subscriptionState string) EntitlementPayload {
return buildEntitlementPayloadFromLicensing(status, subscriptionState)
}
func buildRuntimeCapabilitiesPayload(
status *licenseStatus,
subscriptionState string,
) RuntimeCapabilitiesPayload {
return buildRuntimeCapabilitiesPayloadFromLicensing(status, subscriptionState)
}
func buildCommercialPosturePayload(
status *licenseStatus,
subscriptionState string,
) CommercialPosturePayload {
return buildCommercialPosturePayloadFromLicensing(status, subscriptionState)
}
// buildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage.
func buildEntitlementPayloadWithUsage(
status *licenseStatus,
subscriptionState string,
usage entitlementUsageSnapshot,
trialEndsAtUnix *int64,
) EntitlementPayload {
return buildEntitlementPayloadWithUsageFromLicensing(status, subscriptionState, usage, trialEndsAtUnix)
}
func buildRuntimeCapabilitiesPayloadWithUsage(
status *licenseStatus,
subscriptionState string,
usage entitlementUsageSnapshot,
) RuntimeCapabilitiesPayload {
return buildRuntimeCapabilitiesPayloadWithUsageFromLicensing(status, subscriptionState, usage)
}
func buildCommercialPosturePayloadWithUsage(
status *licenseStatus,
subscriptionState string,
usage entitlementUsageSnapshot,
trialEndsAtUnix *int64,
) CommercialPosturePayload {
return buildCommercialPosturePayloadWithUsageFromLicensing(
status,
subscriptionState,
usage,
trialEndsAtUnix,
)
}
func commercialPosturePayloadFromEntitlementPayload(
payload EntitlementPayload,
) CommercialPosturePayload {
return commercialPosturePayloadFromEntitlementPayloadFromLicensing(payload)
}
// limitState returns the over-limit UX state string.
// Exported for testing.
func limitState(current, limit int64) string {
return limitStateFromLicensing(current, limit)
}
// ensureOnboardingOverflow lazy-initializes the overflow grant for free-tier workspaces.
// Returns the OverflowGrantedAt timestamp (may be nil for non-free tiers or missing billing store).
func (h *LicenseHandlers) ensureOnboardingOverflow(ctx context.Context, tier licenseTier) *int64 {
if tier != licenseTierFreeValue || h == nil || h.mtPersistence == nil {
return nil
}
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
existing, err := billingStore.GetBillingState(orgID)
if err != nil {
return nil
}
now := time.Now().Unix()
if existing == nil {
// No billing state yet. Re-read to handle the case where a concurrent
// request (e.g. trial start) created billing state between our reads.
fresh, freshErr := billingStore.GetBillingState(orgID)
if freshErr != nil {
return nil
}
if fresh != nil {
// Another request created billing state first — add overflow to it.
if fresh.OverflowGrantedAt != nil {
return fresh.OverflowGrantedAt
}
fresh.OverflowGrantedAt = &now
if saveErr := billingStore.SaveBillingState(orgID, fresh); saveErr != nil {
return nil
}
return &now
}
// Still nil — create minimal state with only the overflow grant.
// Use empty subscription state (not trial) to avoid accidentally changing
// the org's subscription lifecycle.
state := &billingState{
Capabilities: []string{},
Limits: map[string]int64{},
MetersEnabled: []string{},
OverflowGrantedAt: &now,
}
if saveErr := billingStore.SaveBillingState(orgID, state); saveErr != nil {
return nil
}
return &now
}
// Already granted — return existing timestamp (set-once).
if existing.OverflowGrantedAt != nil {
return existing.OverflowGrantedAt
}
// First access with existing billing state but no overflow yet — grant now.
// Re-read to minimize the race window with concurrent billing state writers
// (e.g. trial start). We only touch OverflowGrantedAt, preserving all other fields.
fresh, freshErr := billingStore.GetBillingState(orgID)
if freshErr != nil || fresh == nil {
return nil
}
if fresh.OverflowGrantedAt != nil {
// Another request won the race — use their timestamp.
return fresh.OverflowGrantedAt
}
fresh.OverflowGrantedAt = &now
if saveErr := billingStore.SaveBillingState(orgID, fresh); saveErr != nil {
return nil
}
return &now
}
// overflowGrantedAtForContext returns the OverflowGrantedAt timestamp for the
// current org, reading from the evaluator first (hosted path), then falling
// back to billing state on disk (self-hosted path). Does NOT lazy-initialize.
func (h *LicenseHandlers) overflowGrantedAtForContext(ctx context.Context) *int64 {
if h == nil || h.mtPersistence == nil {
return nil
}
// Hosted path: evaluator already has OverflowGrantedAt cached.
svc, _, err := h.getTenantComponents(ctx)
if err == nil && svc != nil {
if eval := svc.Evaluator(); eval != nil {
return eval.OverflowGrantedAt()
}
}
// Self-hosted path: read from billing state directly.
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
existing, readErr := billingStore.GetBillingState(orgID)
if readErr != nil || existing == nil {
return nil
}
return existing.OverflowGrantedAt
}
func (h *LicenseHandlers) billingStateForContext(ctx context.Context) *billingState {
if h == nil || h.mtPersistence == nil {
return nil
}
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
existing, err := billingStore.GetBillingState(orgID)
if err != nil {
return nil
}
return existing
}
func (h *LicenseHandlers) trialStartEligibility(ctx context.Context, svc *licenseService, existing *billingState) (eligible bool, reason string) {
if h == nil || h.mtPersistence == nil {
return false, "unavailable"
}
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
if existing == nil {
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
loaded, err := billingStore.GetBillingState(orgID)
if err != nil {
return false, "unavailable"
}
existing = loaded
}
hasActiveLicense := svc != nil && svc.Current() != nil && svc.IsValid()
decision := evaluateTrialStartEligibilityFromLicensing(hasActiveLicense, existing)
if decision.Allowed {
return true, ""
}
return false, string(decision.Reason)
}