Files
vantage/shared/license/plans.go
T
mrhid6andClaude Opus 5 3fc726da9e feat(license): two deployments times three tiers, and two new limits
Plans are keyed on (deployment, tier) rather than tier alone, which is what
ends Free being cloud-only by construction - there is a self-hosted Free
plan now, so the one-per-account rule has to be enforced per deployment
instead of falling out of the plan table.

tier self_hosted becomes a legacy value no new licence carries.
NormaliseTier maps it to self-hosted Professional, which is what it always
granted, so blobs we cannot re-sign keep working.

Limits.FillUnset exists because a licence signed before a field existed
decodes it as 0, and 0 would read as no monitors and an audit log trimmed
to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:41:43 +01:00

130 lines
4.8 KiB
Go

package license
// Plan is the contents of one (deployment, tier) pair at issue time.
//
// This table is the seed. The admin service owns the authoritative copy in its
// `plans` collection, and every issued licence snapshots the plan it was cut
// from — so editing a plan never rewrites an existing licence, the same rule as
// workflow_runs.steps_snapshot.
//
// Limits here are the BASE allowance: what the tier grants before anything is
// bought. A metered dimension adds to it, which is why max_servers is a real
// number at Professional and Enterprise rather than Unlimited.
type Plan struct {
Tier string
Name string
Deployment string
SupportLevel string
Limits Limits
Features []string
}
// planKey is the composite the table is keyed on.
//
// Keying on tier alone was what made Free cloud-only by construction. There is
// now a self-hosted Free plan, so that guarantee is gone and the Free limit is
// enforced per account AND deployment instead. See licensing.checkFreeLimit.
type planKey struct {
Deployment string
Tier string
}
// baseFree, baseProfessional and baseEnterprise are shared by both deployments.
//
// The allowances are deliberately identical across cloud and self-hosted: what
// differs between the two is the term on offer, not what you get. Duplicating
// them per deployment would be four places to forget.
var (
baseFree = Limits{
MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1,
MaxChannels: 1, AuditRetentionDays: 30,
}
baseProfessional = Limits{
MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
MaxChannels: Unlimited, AuditRetentionDays: 365,
}
baseEnterprise = Limits{
MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
MaxChannels: Unlimited, AuditRetentionDays: Unlimited,
}
)
var plans = map[planKey]Plan{
planKey{DeploymentCloud, TierFree}: {
Tier: TierFree, Name: "Free", Deployment: DeploymentCloud,
SupportLevel: SupportCommunity, Limits: baseFree,
// Empty rather than nil: nil marshals as JSON null, and this table is
// the seed every plan and licence is cut from.
Features: []string{},
},
planKey{DeploymentCloud, TierProfessional}: {
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud,
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
// Console and SSO are opt-in per customer, so no tier bundles them. The
// field stays because a future tier might.
Features: []string{},
},
planKey{DeploymentCloud, TierEnterprise}: {
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierFree}: {
Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
SupportLevel: SupportCommunity, Limits: baseFree,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierProfessional}: {
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
Features: []string{},
},
planKey{DeploymentSelfHosted, TierEnterprise}: {
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
Features: []string{},
},
}
// PlanFor returns the seed plan for one deployment and tier.
//
// It normalises first, so a legacy self_hosted licence resolves to the plan that
// replaced it rather than to nothing.
func PlanFor(deployment, tier string) (Plan, bool) {
deployment, tier = NormaliseTier(deployment, tier)
p, ok := plans[planKey{deployment, tier}]
return p, ok
}
// NormaliseTier maps a legacy tier forward.
//
// tier "self_hosted" predates deployments being separate from tiers. Such a
// licence granted what Professional now grants, on a self-hosted install, so it
// maps to exactly that. Called by PlanFor and by anything reading a tier off an
// already-signed payload.
func NormaliseTier(deployment, tier string) (string, string) {
if tier == TierSelfHosted {
return DeploymentSelfHosted, TierProfessional
}
return deployment, tier
}
// Tiers is the offer order, for any UI that lists them.
func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} }
// Deployments is the offer order.
func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} }
// TermsFor reports which billing terms a deployment sells.
//
// Self-hosted is annual only, and the reason is in this package's doc comment: an
// offline licence cannot be revoked, so the term length IS the revocation
// window. A self-hosted monthly licence would renew that window twelve times a
// year for no commercial gain.
func TermsFor(deployment string) []string {
if deployment == DeploymentSelfHosted {
return []string{"annual"}
}
return []string{"monthly", "annual"}
}