mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
472 lines
17 KiB
Go
472 lines
17 KiB
Go
package licensing
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
// EntitlementPayload is the normalized entitlement response for frontend consumption.
|
|
// Frontend should use this instead of inferring capabilities from tier names.
|
|
type EntitlementPayload struct {
|
|
// Capabilities lists all granted capability keys.
|
|
Capabilities []string `json:"capabilities"`
|
|
|
|
// Limits lists quantitative limits with current usage.
|
|
Limits []LimitStatus `json:"limits"`
|
|
|
|
// SubscriptionState is the current subscription lifecycle state.
|
|
SubscriptionState string `json:"subscription_state"`
|
|
|
|
// UpgradeReasons provides user-actionable upgrade prompts.
|
|
UpgradeReasons []UpgradeReason `json:"upgrade_reasons"`
|
|
|
|
// PlanVersion preserves grandfathered terms.
|
|
PlanVersion string `json:"plan_version,omitempty"`
|
|
|
|
// Tier is the marketing tier name (for display only, never gate on this).
|
|
Tier string `json:"tier"`
|
|
|
|
// TrialExpiresAt is the trial expiration Unix timestamp when in trial state.
|
|
TrialExpiresAt *int64 `json:"trial_expires_at,omitempty"`
|
|
|
|
// TrialDaysRemaining is the number of whole or partial days remaining in trial.
|
|
TrialDaysRemaining *int `json:"trial_days_remaining,omitempty"`
|
|
|
|
// HostedMode indicates that this server is running in Pulse hosted mode.
|
|
// It is used by the frontend to gate hosted-control-plane-only UI.
|
|
HostedMode bool `json:"hosted_mode"`
|
|
|
|
// Valid mirrors the effective license validity for display surfaces.
|
|
Valid bool `json:"valid"`
|
|
|
|
// LicensedEmail is the activated license email when available.
|
|
LicensedEmail string `json:"licensed_email,omitempty"`
|
|
|
|
// ExpiresAt is the RFC3339 expiration timestamp when available.
|
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
|
|
|
// IsLifetime indicates a lifetime entitlement with no expiration.
|
|
IsLifetime bool `json:"is_lifetime"`
|
|
|
|
// DaysRemaining is the number of days left until expiration.
|
|
DaysRemaining int `json:"days_remaining"`
|
|
|
|
// InGracePeriod indicates whether the entitlement is currently in grace.
|
|
InGracePeriod bool `json:"in_grace_period,omitempty"`
|
|
|
|
// GracePeriodEnd is the RFC3339 grace period end timestamp when available.
|
|
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
|
|
|
|
// TrialEligible indicates whether this org can start a self-serve trial right now.
|
|
TrialEligible bool `json:"trial_eligible"`
|
|
|
|
// TrialEligibilityReason is set when trial start is denied.
|
|
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
|
|
|
|
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
|
|
MaxHistoryDays int `json:"max_history_days"`
|
|
|
|
// OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active.
|
|
// Indicates the number of days remaining in the 14-day overflow window.
|
|
OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"`
|
|
|
|
// LegacyConnections is retained for response compatibility. Monitored-system
|
|
// enforcement now counts API-backed and agent-backed top-level systems
|
|
// together, so this field is informational only.
|
|
LegacyConnections LegacyConnectionCounts `json:"legacy_connections"`
|
|
|
|
// HasMigrationGap is retained for response compatibility. API-backed systems
|
|
// now count toward the same monitored-system cap as agent-backed systems.
|
|
HasMigrationGap bool `json:"has_migration_gap"`
|
|
|
|
// CommercialMigration reports unresolved paid-license migration work entering
|
|
// from v5-era commercial state.
|
|
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
|
|
|
|
// MonitoredSystemContinuity exposes migrated monitored-system continuity
|
|
// state for billing and support-grade plan-limit presentation.
|
|
MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
|
|
}
|
|
|
|
// CommercialPosturePayload is the canonical non-billing commercial contract
|
|
// for upgrade messaging, trial posture, and monitored-system migration copy.
|
|
// It intentionally excludes billing identity, grandfathered plan terms, and
|
|
// other full-entitlement details that belong only to billing surfaces.
|
|
type CommercialPosturePayload struct {
|
|
// SubscriptionState is the current subscription lifecycle state.
|
|
SubscriptionState string `json:"subscription_state"`
|
|
|
|
// UpgradeReasons provides user-actionable upgrade prompts.
|
|
UpgradeReasons []UpgradeReason `json:"upgrade_reasons"`
|
|
|
|
// Tier is the marketing tier name (for display only, never gate on this).
|
|
Tier string `json:"tier"`
|
|
|
|
// TrialExpiresAt is the trial expiration Unix timestamp when in trial state.
|
|
TrialExpiresAt *int64 `json:"trial_expires_at,omitempty"`
|
|
|
|
// TrialDaysRemaining is the number of whole or partial days remaining in trial.
|
|
TrialDaysRemaining *int `json:"trial_days_remaining,omitempty"`
|
|
|
|
// TrialEligible indicates whether this org can start a self-serve trial right now.
|
|
TrialEligible bool `json:"trial_eligible"`
|
|
|
|
// TrialEligibilityReason is set when trial start is denied.
|
|
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
|
|
|
|
// OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active.
|
|
OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"`
|
|
|
|
// LegacyConnections is retained for response compatibility. Monitored-system
|
|
// enforcement now counts API-backed and agent-backed top-level systems
|
|
// together, so this field is informational only.
|
|
LegacyConnections LegacyConnectionCounts `json:"legacy_connections"`
|
|
|
|
// HasMigrationGap is retained for response compatibility. API-backed systems
|
|
// now count toward the same monitored-system cap as agent-backed systems.
|
|
HasMigrationGap bool `json:"has_migration_gap"`
|
|
|
|
// CommercialMigration reports unresolved paid-license migration work entering
|
|
// from v5-era commercial state.
|
|
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
|
|
}
|
|
|
|
// RuntimeCapabilitiesPayload is the canonical non-commercial license contract
|
|
// for feature gating and runtime retention/limit decisions.
|
|
type RuntimeCapabilitiesPayload struct {
|
|
// Capabilities lists all granted capability keys.
|
|
Capabilities []string `json:"capabilities"`
|
|
|
|
// Limits lists quantitative limits with current usage.
|
|
Limits []LimitStatus `json:"limits"`
|
|
|
|
// HostedMode indicates that this server is running in Pulse hosted mode.
|
|
HostedMode bool `json:"hosted_mode"`
|
|
|
|
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
|
|
MaxHistoryDays int `json:"max_history_days"`
|
|
}
|
|
|
|
// LimitStatus represents a quantitative limit with current usage state.
|
|
type LimitStatus struct {
|
|
// Key is the limit identifier (e.g., "max_monitored_systems").
|
|
Key string `json:"key"`
|
|
|
|
// Limit is the maximum allowed value (0 = unlimited).
|
|
Limit int64 `json:"limit"`
|
|
|
|
// Current is the observed current usage.
|
|
Current int64 `json:"current"`
|
|
|
|
// CurrentAvailable reports whether Current reflects a resolved runtime
|
|
// usage value rather than an unavailable best-effort fallback.
|
|
CurrentAvailable *bool `json:"current_available,omitempty"`
|
|
|
|
// CurrentUnavailableReason explains why Current is unavailable when
|
|
// CurrentAvailable is false.
|
|
CurrentUnavailableReason string `json:"current_unavailable_reason,omitempty"`
|
|
|
|
// State describes the over-limit UX state.
|
|
// Values: "ok", "warning", "enforced"
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// UpgradeReason provides context for why a user should upgrade.
|
|
type UpgradeReason struct {
|
|
// Key is the capability or limit this reason relates to.
|
|
Key string `json:"key"`
|
|
|
|
// Reason is a user-facing description of why upgrading helps.
|
|
Reason string `json:"reason"`
|
|
|
|
// ActionURL is where the user can go to upgrade.
|
|
ActionURL string `json:"action_url,omitempty"`
|
|
}
|
|
|
|
type LegacyConnectionCounts struct {
|
|
ProxmoxNodes int64 `json:"proxmox_nodes"`
|
|
DockerHosts int64 `json:"docker_hosts"`
|
|
KubernetesClusters int64 `json:"kubernetes_clusters"`
|
|
}
|
|
|
|
func (c LegacyConnectionCounts) Total() int64 {
|
|
return c.ProxmoxNodes + c.DockerHosts + c.KubernetesClusters
|
|
}
|
|
|
|
type EntitlementUsageSnapshot struct {
|
|
MonitoredSystems int64
|
|
MonitoredSystemsAvailable bool
|
|
MonitoredSystemsUnavailableReason string
|
|
// Nodes is retained only as a deprecated compatibility field. V6 monitored-
|
|
// system billing must be backed by an explicit canonical availability signal.
|
|
Nodes int64
|
|
Guests int64
|
|
LegacyConnections LegacyConnectionCounts
|
|
}
|
|
|
|
func (s EntitlementUsageSnapshot) monitoredSystemCount() int64 {
|
|
if !s.MonitoredSystemsAvailable || s.MonitoredSystems < 0 {
|
|
return 0
|
|
}
|
|
return s.MonitoredSystems
|
|
}
|
|
|
|
func (s EntitlementUsageSnapshot) monitoredSystemCountAvailable() bool {
|
|
return s.MonitoredSystemsAvailable
|
|
}
|
|
|
|
func (s EntitlementUsageSnapshot) monitoredSystemCountUnavailableReason() string {
|
|
return s.MonitoredSystemsUnavailableReason
|
|
}
|
|
|
|
// BuildEntitlementPayload constructs the normalized payload from LicenseStatus.
|
|
func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) EntitlementPayload {
|
|
return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil)
|
|
}
|
|
|
|
// BuildCommercialPosturePayload constructs the canonical non-billing
|
|
// commercial posture payload from LicenseStatus.
|
|
func BuildCommercialPosturePayload(
|
|
status *LicenseStatus,
|
|
subscriptionState string,
|
|
) CommercialPosturePayload {
|
|
return BuildCommercialPosturePayloadWithUsage(
|
|
status,
|
|
subscriptionState,
|
|
EntitlementUsageSnapshot{},
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// BuildRuntimeCapabilitiesPayload constructs the canonical non-commercial
|
|
// runtime capability payload from LicenseStatus.
|
|
func BuildRuntimeCapabilitiesPayload(
|
|
status *LicenseStatus,
|
|
subscriptionState string,
|
|
) RuntimeCapabilitiesPayload {
|
|
return BuildRuntimeCapabilitiesPayloadWithUsage(
|
|
status,
|
|
subscriptionState,
|
|
EntitlementUsageSnapshot{},
|
|
)
|
|
}
|
|
|
|
// BuildRuntimeCapabilitiesPayloadWithUsage constructs the canonical
|
|
// non-commercial runtime capability payload from LicenseStatus and observed usage.
|
|
func BuildRuntimeCapabilitiesPayloadWithUsage(
|
|
status *LicenseStatus,
|
|
subscriptionState string,
|
|
usage EntitlementUsageSnapshot,
|
|
) RuntimeCapabilitiesPayload {
|
|
entitlementPayload := BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, nil)
|
|
return RuntimeCapabilitiesPayload{
|
|
Capabilities: append([]string(nil), entitlementPayload.Capabilities...),
|
|
Limits: append([]LimitStatus(nil), entitlementPayload.Limits...),
|
|
HostedMode: entitlementPayload.HostedMode,
|
|
MaxHistoryDays: entitlementPayload.MaxHistoryDays,
|
|
}
|
|
}
|
|
|
|
// BuildCommercialPosturePayloadWithUsage constructs the canonical non-billing
|
|
// commercial posture payload from LicenseStatus and observed usage.
|
|
func BuildCommercialPosturePayloadWithUsage(
|
|
status *LicenseStatus,
|
|
subscriptionState string,
|
|
usage EntitlementUsageSnapshot,
|
|
trialEndsAtUnix *int64,
|
|
) CommercialPosturePayload {
|
|
return CommercialPosturePayloadFromEntitlementPayload(
|
|
BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, trialEndsAtUnix),
|
|
)
|
|
}
|
|
|
|
// CommercialPosturePayloadFromEntitlementPayload projects the non-billing
|
|
// commercial posture fields out of the full entitlement payload.
|
|
func CommercialPosturePayloadFromEntitlementPayload(
|
|
payload EntitlementPayload,
|
|
) CommercialPosturePayload {
|
|
sanitized := CommercialPosturePayload{
|
|
SubscriptionState: payload.SubscriptionState,
|
|
UpgradeReasons: append([]UpgradeReason(nil), payload.UpgradeReasons...),
|
|
Tier: payload.Tier,
|
|
TrialExpiresAt: payload.TrialExpiresAt,
|
|
TrialDaysRemaining: payload.TrialDaysRemaining,
|
|
TrialEligible: payload.TrialEligible,
|
|
TrialEligibilityReason: payload.TrialEligibilityReason,
|
|
OverflowDaysRemaining: payload.OverflowDaysRemaining,
|
|
LegacyConnections: payload.LegacyConnections,
|
|
HasMigrationGap: payload.HasMigrationGap,
|
|
}
|
|
if payload.CommercialMigration != nil {
|
|
sanitized.CommercialMigration = CloneCommercialMigrationStatus(payload.CommercialMigration)
|
|
}
|
|
if sanitized.UpgradeReasons == nil {
|
|
sanitized.UpgradeReasons = []UpgradeReason{}
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
// BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage.
|
|
func BuildEntitlementPayloadWithUsage(
|
|
status *LicenseStatus,
|
|
subscriptionState string,
|
|
usage EntitlementUsageSnapshot,
|
|
trialEndsAtUnix *int64,
|
|
) EntitlementPayload {
|
|
if status == nil {
|
|
return EntitlementPayload{
|
|
Capabilities: []string{},
|
|
Limits: []LimitStatus{},
|
|
SubscriptionState: string(SubStateExpired),
|
|
UpgradeReasons: []UpgradeReason{},
|
|
Tier: string(TierFree),
|
|
MaxHistoryDays: TierHistoryDays[TierFree],
|
|
}
|
|
}
|
|
|
|
maxHistDays := TierHistoryDays[status.Tier]
|
|
if maxHistDays == 0 {
|
|
maxHistDays = TierHistoryDays[TierFree]
|
|
}
|
|
|
|
payload := EntitlementPayload{
|
|
Capabilities: FilterPublicCapabilities(status.Features),
|
|
Limits: []LimitStatus{},
|
|
PlanVersion: status.PlanVersion,
|
|
Tier: string(status.Tier),
|
|
UpgradeReasons: []UpgradeReason{},
|
|
Valid: status.Valid,
|
|
LicensedEmail: status.Email,
|
|
ExpiresAt: status.ExpiresAt,
|
|
IsLifetime: status.IsLifetime,
|
|
DaysRemaining: status.DaysRemaining,
|
|
InGracePeriod: status.InGracePeriod,
|
|
GracePeriodEnd: status.GracePeriodEnd,
|
|
MaxHistoryDays: maxHistDays,
|
|
LegacyConnections: usage.LegacyConnections,
|
|
HasMigrationGap: false,
|
|
}
|
|
if status.MonitoredSystemContinuity != nil {
|
|
continuity := *status.MonitoredSystemContinuity
|
|
payload.MonitoredSystemContinuity = &continuity
|
|
}
|
|
|
|
if payload.Capabilities == nil {
|
|
payload.Capabilities = []string{}
|
|
}
|
|
|
|
// Use provided subscription state when present; otherwise derive from status.
|
|
if subscriptionState == "" {
|
|
subState := SubStateActive
|
|
if !status.Valid {
|
|
subState = SubStateExpired
|
|
} else if status.InGracePeriod {
|
|
subState = SubStateGrace
|
|
}
|
|
subscriptionState = string(subState)
|
|
}
|
|
payload.SubscriptionState = string(GetBehavior(SubscriptionState(subscriptionState)).State)
|
|
|
|
if payload.SubscriptionState == string(SubStateTrial) {
|
|
applyTrialWindow(&payload, status, trialEndsAtUnix, time.Now().Unix())
|
|
}
|
|
|
|
// When subscription state doesn't grant paid features, cap history to free tier.
|
|
if !subscriptionStateHasPaidFeatures(SubscriptionState(payload.SubscriptionState)) {
|
|
payload.MaxHistoryDays = TierHistoryDays[TierFree]
|
|
}
|
|
|
|
// Build limits.
|
|
if status.MaxMonitoredSystems > 0 {
|
|
currentSystems := usage.monitoredSystemCount()
|
|
limit := LimitStatus{
|
|
Key: MaxMonitoredSystemsLicenseGateKey,
|
|
Limit: int64(status.MaxMonitoredSystems),
|
|
Current: currentSystems,
|
|
CurrentAvailable: boolPointer(usage.monitoredSystemCountAvailable()),
|
|
State: LimitState(currentSystems, int64(status.MaxMonitoredSystems)),
|
|
}
|
|
if !usage.monitoredSystemCountAvailable() {
|
|
limit.CurrentUnavailableReason = usage.monitoredSystemCountUnavailableReason()
|
|
}
|
|
payload.Limits = append(payload.Limits, limit)
|
|
}
|
|
if status.MaxGuests > 0 {
|
|
payload.Limits = append(payload.Limits, LimitStatus{
|
|
Key: "max_guests",
|
|
Limit: int64(status.MaxGuests),
|
|
Current: usage.Guests,
|
|
State: LimitState(usage.Guests, int64(status.MaxGuests)),
|
|
})
|
|
}
|
|
|
|
reasons := GenerateUpgradeReasons(payload.Capabilities)
|
|
payload.UpgradeReasons = make([]UpgradeReason, 0, len(reasons))
|
|
for _, reason := range reasons {
|
|
payload.UpgradeReasons = append(payload.UpgradeReasons, UpgradeReason{
|
|
Key: reason.Feature,
|
|
Reason: reason.Reason,
|
|
ActionURL: reason.ActionURL,
|
|
})
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
func applyTrialWindow(payload *EntitlementPayload, status *LicenseStatus, trialEndsAtUnix *int64, nowUnix int64) {
|
|
if payload == nil || status == nil {
|
|
return
|
|
}
|
|
// Prefer billing-state trial timestamps (hosted/self-hosted trial) over license ExpiresAt.
|
|
if trialEndsAtUnix != nil {
|
|
expiresAtUnix := *trialEndsAtUnix
|
|
payload.TrialExpiresAt = &expiresAtUnix
|
|
daysRemaining := remainingTrialDays(expiresAtUnix, nowUnix)
|
|
payload.TrialDaysRemaining = &daysRemaining
|
|
return
|
|
}
|
|
if status.ExpiresAt == nil {
|
|
return
|
|
}
|
|
expiresAt, err := time.Parse(time.RFC3339, *status.ExpiresAt)
|
|
if err != nil {
|
|
return
|
|
}
|
|
expiresAtUnix := expiresAt.Unix()
|
|
payload.TrialExpiresAt = &expiresAtUnix
|
|
daysRemaining := remainingTrialDays(expiresAtUnix, nowUnix)
|
|
payload.TrialDaysRemaining = &daysRemaining
|
|
}
|
|
|
|
func remainingTrialDays(expiresAtUnix, nowUnix int64) int {
|
|
daysRemaining := int(math.Ceil(float64(expiresAtUnix-nowUnix) / 86400.0))
|
|
if daysRemaining < 0 {
|
|
daysRemaining = 0
|
|
}
|
|
return daysRemaining
|
|
}
|
|
|
|
func boolPointer(value bool) *bool {
|
|
v := value
|
|
return &v
|
|
}
|
|
|
|
// LimitState returns the over-limit UX state string.
|
|
func LimitState(current, limit int64) string {
|
|
if limit <= 0 {
|
|
return "ok" // unlimited
|
|
}
|
|
if current >= limit {
|
|
return "enforced"
|
|
}
|
|
// For small limits (≤10, but >1), warn at N-1 so users get notice before hitting the wall.
|
|
// For larger limits, use 90% threshold.
|
|
if limit > 1 && limit <= 10 {
|
|
if current >= limit-1 {
|
|
return "warning"
|
|
}
|
|
} else if current*10 >= limit*9 {
|
|
return "warning"
|
|
}
|
|
return "ok"
|
|
}
|