mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
262 lines
7.7 KiB
Go
262 lines
7.7 KiB
Go
package licensing
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultDatabaseSourceCacheTTL = time.Hour
|
|
|
|
// DatabaseSource implements EntitlementSource from hosted billing state.
|
|
type DatabaseSource struct {
|
|
store BillingStore
|
|
orgID string
|
|
cache *BillingState
|
|
cacheTime time.Time
|
|
cacheTTL time.Duration
|
|
mu sync.RWMutex
|
|
defaults BillingState // trial-equivalent defaults for fail-open
|
|
expectedInstanceHost string
|
|
}
|
|
|
|
// NewDatabaseSource creates a DatabaseSource for a hosted org.
|
|
func NewDatabaseSource(store BillingStore, orgID string, cacheTTL time.Duration) *DatabaseSource {
|
|
// cacheTTL semantics:
|
|
// - cacheTTL > 0: cache for that duration
|
|
// - cacheTTL == 0: no caching (always refresh)
|
|
// - cacheTTL < 0: defaults only (never consult store)
|
|
|
|
return &DatabaseSource{
|
|
store: store,
|
|
orgID: orgID,
|
|
cacheTTL: cacheTTL,
|
|
defaults: BillingState{
|
|
PlanVersion: string(SubStateTrial),
|
|
SubscriptionState: SubStateTrial,
|
|
},
|
|
}
|
|
}
|
|
|
|
// WithExpectedInstanceHost binds hosted entitlement lease validation to the
|
|
// current instance host when a canonical public URL is configured.
|
|
func (d *DatabaseSource) WithExpectedInstanceHost(host string) *DatabaseSource {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
d.expectedInstanceHost = normalizeHost(host)
|
|
return d
|
|
}
|
|
|
|
// Capabilities returns the current capability keys.
|
|
func (d *DatabaseSource) Capabilities() []string {
|
|
return d.currentState().Capabilities
|
|
}
|
|
|
|
// Limits returns the current normalized plan limits.
|
|
func (d *DatabaseSource) Limits() map[string]int64 {
|
|
return d.currentState().Limits
|
|
}
|
|
|
|
// MetersEnabled returns the enabled metering dimensions.
|
|
func (d *DatabaseSource) MetersEnabled() []string {
|
|
return d.currentState().MetersEnabled
|
|
}
|
|
|
|
// PlanVersion returns the current plan version label.
|
|
func (d *DatabaseSource) PlanVersion() string {
|
|
return d.currentState().PlanVersion
|
|
}
|
|
|
|
// SubscriptionState returns the current subscription lifecycle state.
|
|
func (d *DatabaseSource) SubscriptionState() SubscriptionState {
|
|
return d.currentState().SubscriptionState
|
|
}
|
|
|
|
// TrialStartedAt returns the stored trial start timestamp (Unix seconds) when present.
|
|
func (d *DatabaseSource) TrialStartedAt() *int64 {
|
|
return cloneInt64Ptr(d.currentState().TrialStartedAt)
|
|
}
|
|
|
|
// TrialEndsAt returns the stored trial end timestamp (Unix seconds) when present.
|
|
func (d *DatabaseSource) TrialEndsAt() *int64 {
|
|
return cloneInt64Ptr(d.currentState().TrialEndsAt)
|
|
}
|
|
|
|
// OverflowGrantedAt returns the stored overflow grant timestamp (Unix seconds) when present.
|
|
func (d *DatabaseSource) OverflowGrantedAt() *int64 {
|
|
return cloneInt64Ptr(d.currentState().OverflowGrantedAt)
|
|
}
|
|
|
|
func (d *DatabaseSource) currentState() BillingState {
|
|
defaults := d.defaultState()
|
|
if d == nil {
|
|
return normalizeDatabaseSourceState(normalizeTrialExpiry(defaults, time.Now()))
|
|
}
|
|
|
|
cacheTTL := d.cacheTTL
|
|
now := time.Now()
|
|
|
|
// cacheTTL < 0 means "defaults only" (e.g., fail-open / offline mode).
|
|
if cacheTTL < 0 {
|
|
return d.resolveState(defaults, now)
|
|
}
|
|
|
|
// cacheTTL == 0 means "no caching" (always refresh).
|
|
noCache := cacheTTL == 0
|
|
if cacheTTL == 0 {
|
|
// Placeholder value so TTL comparisons compile; guarded by noCache.
|
|
cacheTTL = defaultDatabaseSourceCacheTTL
|
|
}
|
|
|
|
d.mu.RLock()
|
|
if !noCache && d.cache != nil && now.Sub(d.cacheTime) <= cacheTTL {
|
|
cached := cloneBillingState(*d.cache)
|
|
d.mu.RUnlock()
|
|
return d.resolveState(cached, now)
|
|
}
|
|
|
|
var stale BillingState
|
|
hasStale := false
|
|
if d.cache != nil {
|
|
stale = cloneBillingState(*d.cache)
|
|
hasStale = true
|
|
}
|
|
d.mu.RUnlock()
|
|
|
|
if d.store == nil {
|
|
if hasStale {
|
|
return d.resolveState(stale, now)
|
|
}
|
|
return d.resolveState(defaults, now)
|
|
}
|
|
|
|
fresh, err := d.store.GetBillingState(d.orgID)
|
|
if err == nil && fresh != nil {
|
|
cached := cloneBillingState(*fresh)
|
|
cached = d.resolveState(cached, now)
|
|
d.mu.Lock()
|
|
d.cache = &cached
|
|
d.cacheTime = time.Now()
|
|
d.mu.Unlock()
|
|
return cloneBillingState(cached)
|
|
}
|
|
|
|
if hasStale {
|
|
return d.resolveState(stale, now)
|
|
}
|
|
|
|
return d.resolveState(defaults, now)
|
|
}
|
|
|
|
func (d *DatabaseSource) defaultState() BillingState {
|
|
if d == nil {
|
|
return BillingState{
|
|
PlanVersion: string(SubStateTrial),
|
|
SubscriptionState: SubStateTrial,
|
|
}
|
|
}
|
|
|
|
// Clone the full defaults struct so new fields are never silently dropped.
|
|
defaults := cloneBillingState(d.defaults)
|
|
|
|
// Apply fallback values for required fields.
|
|
if defaults.PlanVersion == "" {
|
|
defaults.PlanVersion = string(SubStateTrial)
|
|
}
|
|
if defaults.SubscriptionState == "" {
|
|
defaults.SubscriptionState = SubStateTrial
|
|
}
|
|
|
|
return normalizeDatabaseSourceState(defaults)
|
|
}
|
|
|
|
func cloneBillingState(state BillingState) BillingState {
|
|
// Start with a full value copy so new fields are never silently dropped.
|
|
cp := state
|
|
|
|
// Deep-clone reference types to break aliasing.
|
|
cp.Capabilities = cloneStringSlice(state.Capabilities)
|
|
cp.Limits = cloneInt64Map(state.Limits)
|
|
cp.MetersEnabled = cloneStringSlice(state.MetersEnabled)
|
|
cp.TrialStartedAt = cloneInt64Ptr(state.TrialStartedAt)
|
|
cp.TrialEndsAt = cloneInt64Ptr(state.TrialEndsAt)
|
|
cp.TrialExtendedAt = cloneInt64Ptr(state.TrialExtendedAt)
|
|
cp.OverflowGrantedAt = cloneInt64Ptr(state.OverflowGrantedAt)
|
|
cp.CommercialMigration = CloneCommercialMigrationStatus(state.CommercialMigration)
|
|
|
|
return cp
|
|
}
|
|
|
|
func cloneStringSlice(values []string) []string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
|
|
cloned := make([]string, len(values))
|
|
copy(cloned, values)
|
|
return cloned
|
|
}
|
|
|
|
func cloneInt64Map(values map[string]int64) map[string]int64 {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
|
|
cloned := make(map[string]int64, len(values))
|
|
for key, value := range values {
|
|
cloned[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func normalizeTrialExpiry(state BillingState, now time.Time) BillingState {
|
|
if state.SubscriptionState != SubStateTrial || state.TrialEndsAt == nil {
|
|
return state
|
|
}
|
|
if now.Unix() < *state.TrialEndsAt {
|
|
return state
|
|
}
|
|
|
|
// Trial has expired: mark state as expired and strip capabilities.
|
|
// Free-tier capabilities are granted via tier fallback in license.Service.
|
|
state.SubscriptionState = SubStateExpired
|
|
state.Capabilities = nil
|
|
state.Limits = nil
|
|
state.MetersEnabled = nil
|
|
return state
|
|
}
|
|
|
|
func (d *DatabaseSource) resolveState(state BillingState, now time.Time) BillingState {
|
|
return normalizeDatabaseSourceState(ResolveEntitlementLeaseBillingState(state, d.expectedInstanceHost, now))
|
|
}
|
|
|
|
func normalizeDatabaseSourceState(state BillingState) BillingState {
|
|
normalized := cloneBillingState(state)
|
|
normalized.PlanVersion = CanonicalizePlanVersion(strings.TrimSpace(normalized.PlanVersion))
|
|
normalized.SubscriptionState = SubscriptionState(strings.ToLower(strings.TrimSpace(string(normalized.SubscriptionState))))
|
|
normalized.EntitlementJWT = strings.TrimSpace(normalized.EntitlementJWT)
|
|
normalized.EntitlementRefreshToken = strings.TrimSpace(normalized.EntitlementRefreshToken)
|
|
normalized.StripeCustomerID = strings.TrimSpace(normalized.StripeCustomerID)
|
|
normalized.StripeSubscriptionID = strings.TrimSpace(normalized.StripeSubscriptionID)
|
|
normalized.StripePriceID = strings.TrimSpace(normalized.StripePriceID)
|
|
normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration)
|
|
|
|
normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits)
|
|
|
|
switch normalized.SubscriptionState {
|
|
case SubStateExpired, SubStateSuspended, SubStateCanceled:
|
|
normalized.Capabilities = nil
|
|
normalized.Limits = nil
|
|
normalized.MetersEnabled = nil
|
|
default:
|
|
if limit, known := CloudPlanMonitoredSystemLimits[normalized.PlanVersion]; known {
|
|
if normalized.Limits == nil {
|
|
normalized.Limits = map[string]int64{}
|
|
}
|
|
normalized.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit)
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|