feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
Server Deploy / deploy (push) Successful in 5m22s
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1. Admin: plans re-keyed on (deployment, tier); new catalogue collection holds every Paddle price ID (one row per priceable component); new entitlements collection holds desired beside granted. admin/internal/catalogue owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the base allowance is subtracted in exactly one place. licensing.Issue now snapshots the instance's granted entitlement, never desired. Free is enforced per account AND deployment. Staff endpoints for plans, catalogue and entitlements; Free self-hosted can be claimed and renewed on its annual term; the reaper stays cloud-only. Server: enforces the monitor cap, audit-log retention (daily sweep, skips Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are filled from the seed plan at the single decode site so old blobs never read as zero. Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance screen, and a catalogue-driven PlanConfigurator mounted on the staff instance page. web shows monitors, audit retention and support level on the licence page. Docs: CLAUDE.md, spec index and plan 5 preamble updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,5 +97,152 @@ func Backfill(ctx context.Context) error {
|
||||
if created > 0 {
|
||||
log.Printf("backfill: recorded %d pre-existing instance owners", created)
|
||||
}
|
||||
|
||||
// Pass 3: plans were keyed on tier alone. Give the two cloud tiers their
|
||||
// deployment, and turn the self_hosted TIER row into the self-hosted
|
||||
// Professional row it always was. Field names move too: limits/features
|
||||
// become base_limits/base_features, because "base" is a different claim.
|
||||
if _, err := db.Admin("plans").UpdateMany(ctx,
|
||||
bson.M{"deployment": bson.M{"$exists": false},
|
||||
"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
|
||||
bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Admin("plans").UpdateOne(ctx,
|
||||
bson.M{"tier": license.TierSelfHosted},
|
||||
bson.M{"$set": bson.M{
|
||||
"deployment": license.DeploymentSelfHosted,
|
||||
"tier": license.TierProfessional,
|
||||
"name": "Professional",
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Admin("plans").UpdateMany(ctx,
|
||||
bson.M{"limits": bson.M{"$exists": true}},
|
||||
bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Support level is new, so nothing has one. Fill from the seed table rather
|
||||
// than guessing: a plan row a human edited keeps every other field.
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range license.Tiers() {
|
||||
p, ok := license.PlanFor(deployment, tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("plans").UpdateOne(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier,
|
||||
"support_level": bson.M{"$in": bson.A{nil, ""}}},
|
||||
bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: instances carrying the self_hosted TIER move to Professional.
|
||||
// Their deployment already says self_hosted, so only the tier is wrong.
|
||||
res, err = db.Admin("admin_instances").UpdateMany(ctx,
|
||||
bson.M{"tier": license.TierSelfHosted},
|
||||
bson.M{"$set": bson.M{"tier": license.TierProfessional}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
|
||||
}
|
||||
|
||||
// Pass 5: give every instance an entitlement, reconstructed from its current
|
||||
// licence. Filtering on the absence of a row is what makes this idempotent,
|
||||
// and it means an entitlement a customer has since edited is never
|
||||
// overwritten by a stale licence.
|
||||
if err := backfillEntitlements(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillEntitlements reconstructs an entitlement per instance from its licence.
|
||||
//
|
||||
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
|
||||
// a huge number: an unlimited licence bought no server units, so the honest
|
||||
// reconstruction of "how many did they pay for" is none. This makes a
|
||||
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
|
||||
// what it is allowed. That is deliberate and it is why this is a plan step and
|
||||
// not a silent fix — see the task's confirmation step.
|
||||
func backfillEntitlements(ctx context.Context) error {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx,
|
||||
bson.M{"status": bson.M{"$ne": StatusDeleted}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var instances []Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
created := 0
|
||||
for _, inst := range instances {
|
||||
n, err := db.Admin("entitlements").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
|
||||
if tier == "" {
|
||||
// An instance awaiting its first licence has no tier. It gets an
|
||||
// entitlement when one is issued, not before.
|
||||
continue
|
||||
}
|
||||
plan, err := GetPlan(ctx, deployment, tier)
|
||||
if err != nil {
|
||||
log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
|
||||
inst.InstanceID, deployment, tier)
|
||||
continue
|
||||
}
|
||||
|
||||
cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
|
||||
if inst.CurrentLicense != "" {
|
||||
var lic License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
|
||||
if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
|
||||
cfg.Servers = lic.Limits.MaxServers
|
||||
}
|
||||
cfg.Features = lic.Features.OrEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
limits := plan.BaseLimits
|
||||
limits.MaxServers = cfg.Servers
|
||||
if err := UpsertEntitlement(ctx, Entitlement{
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
Deployment: deployment,
|
||||
Tier: tier,
|
||||
Term: defaultTerm(deployment),
|
||||
Desired: cfg,
|
||||
Granted: cfg,
|
||||
ResolvedLimits: limits,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created++
|
||||
}
|
||||
if created > 0 {
|
||||
log.Printf("backfill: created %d entitlements from current licences", created)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
|
||||
// sells annual only, so there is nothing to guess there.
|
||||
func defaultTerm(deployment string) string {
|
||||
if deployment == license.DeploymentSelfHosted {
|
||||
return "annual"
|
||||
}
|
||||
return "monthly"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// Component kinds.
|
||||
const (
|
||||
// KindBase is the plan's own fee, always quantity 1.
|
||||
KindBase = "base"
|
||||
// KindLimit raises a named limit by one per unit of quantity.
|
||||
KindLimit = "limit"
|
||||
// KindFeature is an on/off feature key.
|
||||
KindFeature = "feature"
|
||||
)
|
||||
|
||||
// LimitKeyServers is the only metered limit today.
|
||||
//
|
||||
// A limit_key is a field name in license.Limits, which is what lets a second
|
||||
// metered dimension be a catalogue row rather than a code change. There is
|
||||
// deliberately no block size: with secret-group blocks dropped from the spec it
|
||||
// would be 1 in every row that will ever exist.
|
||||
const LimitKeyServers = "max_servers"
|
||||
|
||||
// CatalogueRow is one priceable component of one plan.
|
||||
//
|
||||
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
|
||||
// PriceIDs means the component is free — a feature with no price is a toggle a
|
||||
// customer may take at no charge, and giving it a price later is a staff edit
|
||||
// rather than a migration or a deploy.
|
||||
type CatalogueRow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
|
||||
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
|
||||
// PriceIDs is environment -> term -> Paddle price ID, e.g.
|
||||
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
|
||||
//
|
||||
// Nested by environment rather than kept in two collections, because
|
||||
// promoting sandbox to production must be a configuration change and not a
|
||||
// data migration. The running PADDLE_ENV picks the inner map.
|
||||
PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"`
|
||||
}
|
||||
|
||||
// PriceID returns the price for one environment and term, or "".
|
||||
func (r CatalogueRow) PriceID(env, term string) string {
|
||||
if r.PriceIDs == nil {
|
||||
return ""
|
||||
}
|
||||
return r.PriceIDs[env][term]
|
||||
}
|
||||
|
||||
// Priced reports whether this component costs anything in an environment.
|
||||
func (r CatalogueRow) Priced(env string) bool {
|
||||
for _, term := range []string{"monthly", "annual"} {
|
||||
if r.PriceID(env, term) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
// "fix" this by adding zero-priced Free rows.
|
||||
//
|
||||
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
|
||||
// in by staff and a redeploy must not blank them.
|
||||
func SeedCatalogue(ctx context.Context) error {
|
||||
paid := []string{license.TierProfessional, license.TierEnterprise}
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range paid {
|
||||
rows := []CatalogueRow{
|
||||
{Kind: KindBase, Deployment: deployment, Tier: tier},
|
||||
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
}
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
"price_ids": map[string]map[string]string{},
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogueFor returns every component of one plan.
|
||||
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
|
||||
deployment, tier = license.NormaliseTier(deployment, tier)
|
||||
cur, err := db.Admin("catalogue").Find(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []CatalogueRow{}
|
||||
if err := cur.All(ctx, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// AllCatalogue returns every row, for the staff editor.
|
||||
func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) {
|
||||
cur, err := db.Admin("catalogue").Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []CatalogueRow{}
|
||||
if err := cur.All(ctx, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// ErrNoEntitlement means the instance has no configuration row.
|
||||
//
|
||||
// Callers fall back to the plan's base rather than failing: staff manual
|
||||
// issuance and any instance predating the backfill legitimately have none.
|
||||
var ErrNoEntitlement = errors.New("instance has no entitlement")
|
||||
|
||||
// Config is one side of an entitlement — a complete statement of what an
|
||||
// instance is allowed.
|
||||
//
|
||||
// Servers is the TOTAL the customer sees, not the number of units billed. The
|
||||
// billed quantity is Servers minus the plan's base allowance, and it is computed
|
||||
// where the line items are built rather than stored, so the two can never
|
||||
// disagree about which of them included the base.
|
||||
type Config struct {
|
||||
Servers int `bson:"servers" json:"servers"`
|
||||
Features Features `bson:"features" json:"features"`
|
||||
}
|
||||
|
||||
// Entitlement is what one instance's customer configured.
|
||||
//
|
||||
// Both the subscription and the licence are derived from it; it is derived from
|
||||
// nothing. Desired is what they last asked for; Granted is what a payment
|
||||
// confirmed. A licence is only ever signed from Granted, so an abandoned
|
||||
// checkout leaves a Desired that reached nothing.
|
||||
type Entitlement struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Term string `bson:"term" json:"term"`
|
||||
|
||||
Desired Config `bson:"desired" json:"desired"`
|
||||
Granted Config `bson:"granted" json:"granted"`
|
||||
|
||||
// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
|
||||
// than derived on read so the fold lives in exactly one place — deriving it
|
||||
// at every read would put the arithmetic in the issuer, the portal and the
|
||||
// staff console.
|
||||
ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`
|
||||
|
||||
// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
|
||||
// when Desired grants less than Granted, and it is what lets the portal say
|
||||
// "drops to 5 on 12 August" instead of guessing.
|
||||
ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`
|
||||
|
||||
GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Pending reports whether Desired and Granted disagree.
|
||||
func (e Entitlement) Pending() bool {
|
||||
if e.Desired.Servers != e.Granted.Servers {
|
||||
return true
|
||||
}
|
||||
if len(e.Desired.Features) != len(e.Granted.Features) {
|
||||
return true
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, f := range e.Granted.Features {
|
||||
have[f] = true
|
||||
}
|
||||
for _, f := range e.Desired.Features {
|
||||
if !have[f] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetEntitlement reads one instance's configuration.
|
||||
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
|
||||
var e Entitlement
|
||||
err := db.Admin("entitlements").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&e)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNoEntitlement
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpsertEntitlement writes an entitlement, creating it if absent.
|
||||
//
|
||||
// GrantedAt is only touched when Granted actually changes, which is what makes
|
||||
// it answer "since when has this instance been allowed this" rather than "when
|
||||
// was this row last written".
|
||||
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
|
||||
now := time.Now().UTC()
|
||||
set := bson.M{
|
||||
"account_id": e.AccountID,
|
||||
"deployment": e.Deployment,
|
||||
"tier": e.Tier,
|
||||
"term": e.Term,
|
||||
"desired": e.Desired,
|
||||
"granted": e.Granted,
|
||||
"resolved_limits": e.ResolvedLimits,
|
||||
"updated_at": now,
|
||||
}
|
||||
if e.ScheduledChangeAt != nil {
|
||||
set["scheduled_change_at"] = *e.ScheduledChangeAt
|
||||
}
|
||||
if !e.GrantedAt.IsZero() {
|
||||
set["granted_at"] = e.GrantedAt
|
||||
} else {
|
||||
set["granted_at"] = now
|
||||
}
|
||||
update := bson.M{"$set": set}
|
||||
if e.ScheduledChangeAt == nil {
|
||||
update["$unset"] = bson.M{"scheduled_change_at": ""}
|
||||
}
|
||||
_, err := db.Admin("entitlements").UpdateOne(ctx,
|
||||
bson.M{"instance_id": e.InstanceID},
|
||||
mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
|
||||
// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
|
||||
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
|
||||
update["$setOnInsert"] = onInsert
|
||||
return update
|
||||
}
|
||||
@@ -68,6 +68,13 @@ const (
|
||||
ReasonTierChange = "tier_change"
|
||||
ReasonRelink = "relink"
|
||||
ReasonManual = "manual"
|
||||
|
||||
// ReasonEntitlementChange is a mid-term change to what an instance is
|
||||
// allowed — servers added, a feature toggled — at the same expiry.
|
||||
//
|
||||
// It is deliberately NOT ReasonRenewal: a renewal resets relink_count
|
||||
// because a new term has begun, and adding a server does not begin one.
|
||||
ReasonEntitlementChange = "entitlement_change"
|
||||
)
|
||||
|
||||
// MaxRelinksPerTerm is the customer-facing relink cap.
|
||||
@@ -158,21 +165,28 @@ type Subscription struct {
|
||||
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
|
||||
}
|
||||
|
||||
// Plan is the authoritative tier definition, seeded from shared/license.
|
||||
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
|
||||
// from shared/license.
|
||||
//
|
||||
// It lives in the database so tier contents change without a deploy. Every
|
||||
// issued licence snapshots it, so editing a plan never rewrites an existing
|
||||
// licence — the same rule as workflow_runs.steps_snapshot.
|
||||
//
|
||||
// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a
|
||||
// metered plan is priced by several components and a single map on this row
|
||||
// cannot express that.
|
||||
type Plan struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
Features Features `bson:"features" json:"features"`
|
||||
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
|
||||
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
// BaseLimits is the allowance before anything is bought. The field is named
|
||||
// `base_` rather than `limits` because that is a different claim from the one
|
||||
// the old field made, and a reader must not assume it is the total.
|
||||
BaseLimits license.Limits `bson:"base_limits" json:"base_limits"`
|
||||
BaseFeatures Features `bson:"base_features" json:"base_features"`
|
||||
SupportLevel string `bson:"support_level" json:"support_level"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
}
|
||||
|
||||
type StaffUser struct {
|
||||
|
||||
@@ -10,38 +10,48 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// SeedPlans inserts the tier table from shared/license on first boot.
|
||||
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
|
||||
// boot.
|
||||
//
|
||||
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
|
||||
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
|
||||
// It uses $setOnInsert only: once a plan exists, staff edits to allowances,
|
||||
// features and support level are authoritative and a redeploy must not stamp
|
||||
// over them.
|
||||
func SeedPlans(ctx context.Context) error {
|
||||
for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
|
||||
p, ok := license.PlanFor(tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := db.Admin("plans").UpdateOne(ctx,
|
||||
bson.M{"tier": tier},
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"tier": p.Tier,
|
||||
"name": p.Name,
|
||||
"deployment": p.Deployment,
|
||||
"limits": p.Limits,
|
||||
"features": Features(p.Features).OrEmpty(),
|
||||
"active": true,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
if err != nil {
|
||||
return err
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range license.Tiers() {
|
||||
p, ok := license.PlanFor(deployment, tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := db.Admin("plans").UpdateOne(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier},
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"deployment": p.Deployment,
|
||||
"tier": p.Tier,
|
||||
"name": p.Name,
|
||||
"base_limits": p.Limits,
|
||||
"base_features": Features(p.Features).OrEmpty(),
|
||||
"support_level": p.SupportLevel,
|
||||
"active": true,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlan reads a tier's authoritative definition.
|
||||
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
|
||||
// GetPlan reads one pair's authoritative definition.
|
||||
//
|
||||
// It normalises the tier first, so a legacy self_hosted licence being reissued
|
||||
// resolves to the plan that replaced it.
|
||||
func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) {
|
||||
deployment, tier = license.NormaliseTier(deployment, tier)
|
||||
var p Plan
|
||||
if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
|
||||
if err := db.Admin("plans").FindOne(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
|
||||
Reference in New Issue
Block a user