feat: Updated plans and catalogue pages

This commit is contained in:
2026-08-25 14:35:47 +00:00
parent 270d55e6a6
commit 3e4865884c
18 changed files with 825 additions and 390 deletions
+4
View File
@@ -86,6 +86,10 @@ func main() {
idxCancel()
log.Fatalf("seed catalogue: %v", err)
}
if err := models.MigrateSharedCatalogue(idxCtx); err != nil {
idxCancel()
log.Fatalf("migrate catalogue: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
+21 -1
View File
@@ -485,6 +485,7 @@ func staffListCatalogue(c *gin.Context) {
func staffUpdateCatalogue(c *gin.Context) {
var body struct {
Kind string `json:"kind"`
Scope string `json:"scope"`
Deployment string `json:"deployment"`
Tier string `json:"tier"`
LimitKey string `json:"limit_key"`
@@ -500,11 +501,19 @@ func staffUpdateCatalogue(c *gin.Context) {
// mean a resolved self-hosted monthly price later, which the resolver treats
// as a configuration error — better to refuse it at the point somebody
// pastes it, while they are looking at the screen.
//
// A shared row is sold by both deployments, so both terms are legitimate on
// it: the cloud checkout takes the monthly price and the self-hosted one
// never asks for it. Only a plan row can name a term its own deployment
// does not sell.
for env, byTerm := range body.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if body.Scope == models.ScopeShared {
continue
}
if !termSold(body.Deployment, term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
@@ -514,6 +523,8 @@ func staffUpdateCatalogue(c *gin.Context) {
}
}
// Addressed by its natural key, so the staff UI never holds a Mongo id. A
// shared row's empty deployment and tier are part of that key.
filter := bson.M{
"kind": body.Kind,
"deployment": body.Deployment,
@@ -534,12 +545,21 @@ func staffUpdateCatalogue(c *gin.Context) {
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "catalogue.updated",
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
Target: catalogueTarget(body.Scope, body.Deployment, body.Tier, body.Kind),
Detail: body.LimitKey + body.FeatureKey,
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
// catalogueTarget names an edited component in the audit log. A shared row has
// no plan to name, so it says so rather than logging "//feature".
func catalogueTarget(scope, deployment, tier, kind string) string {
if scope == models.ScopeShared {
return "shared/" + kind
}
return deployment + "/" + tier + "/" + kind
}
func termSold(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
+12 -2
View File
@@ -29,8 +29,18 @@ func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg mod
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
// A plan is identified by its base row, and shared add-on rows exist whether
// or not any plan sells them — so "the catalogue returned something" is no
// longer proof this plan is priced. Check for the base row itself.
hasBase := false
for _, r := range rows {
if r.Kind == models.KindBase {
hasBase = true
break
}
}
if !hasBase {
return nil, fmt.Errorf("%w: %s/%s has no base row",
ErrUnpriced, plan.Deployment, plan.Tier)
}
+183 -46
View File
@@ -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
}