|
|
|
@@ -2,6 +2,8 @@ package models
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
|
|
|
|
|
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
|
|
|
|
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
|
|
|
@@ -19,6 +21,23 @@ const (
|
|
|
|
|
KindFeature = "feature"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Component scopes.
|
|
|
|
|
//
|
|
|
|
|
// A component is priced by one Paddle product, and how many catalogue rows it
|
|
|
|
|
// needs follows from how many products it is. The base fee is a different
|
|
|
|
|
// product per plan, so it is a row per plan. Every add-on — the server limit and
|
|
|
|
|
// all four features — is ONE product sold to every paid plan at one price, so it
|
|
|
|
|
// is one row, and its price ID is typed once instead of four times.
|
|
|
|
|
//
|
|
|
|
|
// Scope is stored rather than inferred from Kind so the rule is data. Pricing a
|
|
|
|
|
// future add-on per tier is then a scope on a row, not a rewrite of every reader.
|
|
|
|
|
const (
|
|
|
|
|
// ScopePlan rows carry a deployment and a tier and belong to that plan alone.
|
|
|
|
|
ScopePlan = "plan"
|
|
|
|
|
// ScopeShared rows leave deployment and tier empty and belong to every paid plan.
|
|
|
|
|
ScopeShared = "shared"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// LimitKeyServers is the only metered limit today.
|
|
|
|
|
//
|
|
|
|
|
// A limit_key is a field name in license.Limits, which is what lets a second
|
|
|
|
@@ -27,19 +46,23 @@ const (
|
|
|
|
|
// would be 1 in every row that will ever exist.
|
|
|
|
|
const LimitKeyServers = "max_servers"
|
|
|
|
|
|
|
|
|
|
// CatalogueRow is one priceable component of one plan.
|
|
|
|
|
// CatalogueRow is one priceable component.
|
|
|
|
|
//
|
|
|
|
|
// 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"`
|
|
|
|
|
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
|
|
|
|
Kind string `bson:"kind" json:"kind"`
|
|
|
|
|
Scope string `bson:"scope" json:"scope"`
|
|
|
|
|
// Deployment and Tier are empty on a shared row, and are what a plan row is
|
|
|
|
|
// keyed by. Readers must go through CatalogueFor rather than filtering on
|
|
|
|
|
// them, or a shared row is invisible to the plan that sells it.
|
|
|
|
|
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_…"}}.
|
|
|
|
|
//
|
|
|
|
@@ -67,60 +90,174 @@ func (r CatalogueRow) Priced(env string) bool {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SeedCatalogue inserts the twenty-four rows the four PAID plans need: a base, a
|
|
|
|
|
// server limit, and one row per feature key. The count is deliberate — it moves
|
|
|
|
|
// whenever shared/license gains a feature, and this comment is how the next
|
|
|
|
|
// person knows the number was chosen rather than drifted.
|
|
|
|
|
// Shared reports whether this row is sold by every paid plan.
|
|
|
|
|
func (r CatalogueRow) Shared() bool { return r.Scope == ScopeShared }
|
|
|
|
|
|
|
|
|
|
// naturalKey is how a row is addressed everywhere: by what it is, never by its
|
|
|
|
|
// ObjectID. A shared row's deployment and tier are empty, and that emptiness is
|
|
|
|
|
// part of the key rather than a wildcard.
|
|
|
|
|
func (r CatalogueRow) naturalKey() bson.M {
|
|
|
|
|
return bson.M{
|
|
|
|
|
"kind": r.Kind,
|
|
|
|
|
"deployment": r.Deployment,
|
|
|
|
|
"tier": r.Tier,
|
|
|
|
|
"limit_key": r.LimitKey,
|
|
|
|
|
"feature_key": r.FeatureKey,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedRows is the catalogue as it should exist: four base rows, one per paid
|
|
|
|
|
// plan, plus five shared add-on rows every paid plan sells.
|
|
|
|
|
//
|
|
|
|
|
// Nine rows, down from twenty-four. The count moves whenever shared/license
|
|
|
|
|
// gains a feature, and this comment is how the next person knows the number was
|
|
|
|
|
// chosen rather than drifted.
|
|
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
func seedRows() []CatalogueRow {
|
|
|
|
|
rows := []CatalogueRow{}
|
|
|
|
|
paid := []string{license.TierProfessional, license.TierEnterprise}
|
|
|
|
|
for _, deployment := range license.Deployments() {
|
|
|
|
|
for _, tier := range paid {
|
|
|
|
|
rows = append(rows, CatalogueRow{
|
|
|
|
|
Kind: KindBase, Scope: ScopePlan, Deployment: deployment, Tier: tier,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rows = append(rows, CatalogueRow{
|
|
|
|
|
Kind: KindLimit, Scope: ScopeShared, LimitKey: LimitKeyServers,
|
|
|
|
|
})
|
|
|
|
|
for _, f := range []string{
|
|
|
|
|
license.FeatureConsole,
|
|
|
|
|
license.FeatureOIDC,
|
|
|
|
|
license.FeatureVulnScanning,
|
|
|
|
|
license.FeatureStatusPages,
|
|
|
|
|
} {
|
|
|
|
|
rows = append(rows, CatalogueRow{
|
|
|
|
|
Kind: KindFeature, Scope: ScopeShared, FeatureKey: f,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SeedCatalogue inserts the nine rows the four paid plans need.
|
|
|
|
|
//
|
|
|
|
|
// $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},
|
|
|
|
|
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
|
|
|
|
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureStatusPages},
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, r := range seedRows() {
|
|
|
|
|
set := r.naturalKey()
|
|
|
|
|
set["scope"] = r.Scope
|
|
|
|
|
set["price_ids"] = map[string]map[string]string{}
|
|
|
|
|
if _, err := db.Admin("catalogue").UpdateOne(ctx, r.naturalKey(),
|
|
|
|
|
bson.M{"$setOnInsert": set},
|
|
|
|
|
options.UpdateOne().SetUpsert(true)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CatalogueFor returns every component of one plan.
|
|
|
|
|
// MigrateSharedCatalogue collapses the four per-plan copies of each add-on onto
|
|
|
|
|
// the one shared row, and deletes the copies.
|
|
|
|
|
//
|
|
|
|
|
// It runs after SeedCatalogue, which has already created the shared rows empty,
|
|
|
|
|
// and is idempotent: once the per-plan copies are gone there is nothing to move.
|
|
|
|
|
//
|
|
|
|
|
// It REFUSES rather than guesses when the copies disagree. Four rows that were
|
|
|
|
|
// meant to be one price and are not is a real pricing decision somebody made,
|
|
|
|
|
// and picking one of them silently would move a customer's bill.
|
|
|
|
|
func MigrateSharedCatalogue(ctx context.Context) error {
|
|
|
|
|
// Rows seeded before scope existed are all per-plan rows. Naming them so
|
|
|
|
|
// keeps CatalogueFor's $or honest for the base rows that survive.
|
|
|
|
|
if _, err := db.Admin("catalogue").UpdateMany(ctx,
|
|
|
|
|
bson.M{"scope": bson.M{"$exists": false}},
|
|
|
|
|
bson.M{"$set": bson.M{"scope": ScopePlan}}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, shared := range seedRows() {
|
|
|
|
|
if !shared.Shared() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
cur, err := db.Admin("catalogue").Find(ctx, bson.M{
|
|
|
|
|
"kind": shared.Kind,
|
|
|
|
|
"limit_key": shared.LimitKey,
|
|
|
|
|
"feature_key": shared.FeatureKey,
|
|
|
|
|
"deployment": bson.M{"$ne": ""},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
old := []CatalogueRow{}
|
|
|
|
|
if err := cur.All(ctx, &old); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if len(old) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var target CatalogueRow
|
|
|
|
|
if err := db.Admin("catalogue").FindOne(ctx, shared.naturalKey()).Decode(&target); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
merged := target.PriceIDs
|
|
|
|
|
if merged == nil {
|
|
|
|
|
merged = map[string]map[string]string{}
|
|
|
|
|
}
|
|
|
|
|
for _, o := range old {
|
|
|
|
|
for env, byTerm := range o.PriceIDs {
|
|
|
|
|
for term, id := range byTerm {
|
|
|
|
|
if id == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if merged[env] == nil {
|
|
|
|
|
merged[env] = map[string]string{}
|
|
|
|
|
}
|
|
|
|
|
if have := merged[env][term]; have != "" && have != id {
|
|
|
|
|
return fmt.Errorf(
|
|
|
|
|
"catalogue: %s%s was priced differently per plan (%s %s: %q and %q); "+
|
|
|
|
|
"decide which price is the shared one and delete the others before upgrading",
|
|
|
|
|
shared.LimitKey, shared.FeatureKey, env, term, have, id)
|
|
|
|
|
}
|
|
|
|
|
merged[env][term] = id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := db.Admin("catalogue").UpdateOne(ctx, shared.naturalKey(),
|
|
|
|
|
bson.M{"$set": bson.M{"price_ids": merged}}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
ids := make([]bson.ObjectID, 0, len(old))
|
|
|
|
|
for _, o := range old {
|
|
|
|
|
ids = append(ids, o.ID)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Admin("catalogue").DeleteMany(ctx,
|
|
|
|
|
bson.M{"_id": bson.M{"$in": ids}}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
log.Printf("catalogue: merged %d per-plan rows into shared %s%s",
|
|
|
|
|
len(old), shared.LimitKey, shared.FeatureKey)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CatalogueFor returns every component one plan sells: its own base row plus
|
|
|
|
|
// every shared add-on.
|
|
|
|
|
//
|
|
|
|
|
// This is the seam the whole shared-row change rests on. Every reader that used
|
|
|
|
|
// to filter the catalogue by deployment and tier must come through here instead,
|
|
|
|
|
// or it sees a plan priced by nothing but its base fee.
|
|
|
|
|
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})
|
|
|
|
|
cur, err := db.Admin("catalogue").Find(ctx, bson.M{"$or": []bson.M{
|
|
|
|
|
{"scope": ScopeShared},
|
|
|
|
|
{"deployment": deployment, "tier": tier},
|
|
|
|
|
}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|