diff --git a/CLAUDE.md b/CLAUDE.md index e5ea4f2..e2ab353 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ vantage/ │ └── models/ # accounts, instances, licences, plans ├── adminsite/ # staff + customer console (vantage-hq) │ ├── app/(customer)/ # overview, instance, link, billing -│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit +│ ├── app/(staff)/staff/ # operations, accounts, licences, pricing, audit │ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord │ └── lib/ # api client, session guards, formatters ├── docsite/ # user documentation (Docusaurus, static) @@ -947,9 +947,9 @@ Notes that are not obvious from the structs: Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. -`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle. +`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. A row carries a `scope`: `plan` rows name a `deployment` and `tier` and belong to that plan alone, `shared` rows leave both empty and are sold by every paid plan. **How many rows a component needs follows from how many Paddle products it is** — the base fee is a different product per plan, every add-on is one product at one price, so the catalogue is four base rows plus five shared rows, nine instead of twenty-four, and an add-on's price ID is typed once rather than four times. `models.CatalogueFor` is the seam: it returns a plan's base row plus every shared row, and **nothing may filter the catalogue by `deployment` and `tier` itself** or it sees a plan priced by its base fee alone. `adminsite/lib/catalogue.ts`'s `rowsForPlan` is the TypeScript half of that and must change in the same commit, the same shape of hazard as `web/lib/targets.ts`. `models.MigrateSharedCatalogue` runs at boot after `SeedCatalogue`, merges the old per-plan copies onto the shared row and deletes them; it **refuses rather than guesses** when the four copies disagree, because four rows meant to be one price and are not is a pricing decision somebody made and picking one silently moves a customer's bill. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle. -**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more `KindFeature` row per paid plan in `SeedCatalogue` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on `(kind, deployment, tier, feature_key)`, so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path. +**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more shared `KindFeature` row in `SeedCatalogue`'s `seedRows` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on the row's natural key `(kind, deployment, tier, limit_key, feature_key)` — a shared row's empty deployment and tier are part of that key, not a wildcard — so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path. ### Migrations @@ -1128,6 +1128,27 @@ Tailwind in all three maps `var(--…)` references only, so **no component in an `web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it. +**Plans and the catalogue are one page, `/staff/pricing`.** They were two nav +entries and the split asked staff to hold one half in their head while looking +at the other: a tier's allowance is what the metered component charges above, +and a base fee means nothing without the allowance it includes. The page is +`PlansSection` then `CatalogueSection`, in the order the decision is made — +what a tier grants, then what it costs. `next.config.ts` keeps permanent +redirects from `/staff/plans` and `/staff/catalogue`, which are bookmarked in +staff browsers. **The tier list is cards, not forms**: six plans with five +number fields, a select, a checkbox and four feature toggles each was forty-odd +controls on one screen, and the page could not be read for the thing it exists +to answer. A card states what the tier grants and `Modal` — a native +``, for the focus trap and Escape handling a hand-rolled overlay gets +wrong — is where it is changed. Every feature key renders on every card, lit or +unlit: no tier bundles one today, so the unlit row is the information. + +**The catalogue's coverage ledger is not decoration.** A missing production +price is invisible in a grid of text inputs — every cell looks like every other +until twenty-six characters of each are read — and it is the one thing staff +come to the page to check before a launch, so each component draws one filled +or empty square per environment and term. + **The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan. Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions. diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 2bc9cfa..0c64aa6 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -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) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 54b7988..68c37e4 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -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 { diff --git a/admin/internal/catalogue/items.go b/admin/internal/catalogue/items.go index ecd3f16..c5af645 100644 --- a/admin/internal/catalogue/items.go +++ b/admin/internal/catalogue/items.go @@ -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) } diff --git a/admin/internal/models/catalogue.go b/admin/internal/models/catalogue.go index 366fd09..20c3b01 100644 --- a/admin/internal/models/catalogue.go +++ b/admin/internal/models/catalogue.go @@ -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 } diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx index df9da0c..bdb74e3 100644 --- a/adminsite/app/(customer)/purchase/PurchaseForm.tsx +++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { rowsForPlan, sharedRows } from "@/lib/catalogue"; import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api"; import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle"; import { featureDesc, featureLabel } from "@/lib/features"; @@ -60,24 +61,24 @@ export function PurchaseForm() { const options = optionsQ.data; const accountId = account.data?.account.account_id ?? ""; - // Distinct feature keys offered on this deployment, in a stable order. + // Every feature a paid plan can be sold, in a stable order. Features are + // shared rows now, so they no longer differ by deployment — the list is the + // same on both, and reads from one place rather than four. const featureKeys = useMemo(() => { if (!options) return [] as string[]; const keys = new Set(); - for (const r of options.catalogue) { - if (r.deployment === dep && r.kind === "feature" && r.feature_key) { - keys.add(r.feature_key); - } + for (const r of sharedRows(options.catalogue)) { + if (r.kind === "feature" && r.feature_key) keys.add(r.feature_key); } return [...keys]; - }, [options, dep]); + }, [options]); const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]); const plan = activePlans.find((p) => p.tier === choice.tier); const baseServers = plan?.base_limits.max_servers ?? 0; const unlimited = baseServers === -1; - const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]); + const rows = useMemo(() => rowsForPlan(options?.catalogue ?? [], dep, choice.tier), [options, dep, choice.tier]); // Real line items for the current configuration the same builder the // checkout uses, so the summary can never disagree with the overlay. @@ -239,7 +240,7 @@ export function PurchaseForm() { headline={p.tier === "free" ? "£0" : basePrices[p.tier]} cycleLabel={cycleShort(dep, choice.term)} featureKeys={featureKeys} - catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)} + catalogue={rowsForPlan(options.catalogue, dep, p.tier)} env={options.env} term={choice.term} onSelect={() => @@ -252,7 +253,7 @@ export function PurchaseForm() { features: c.features.filter((k) => { const st = featureStateFor( p, - options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier), + rowsForPlan(options.catalogue, dep, p.tier), options.env, c.term, k, @@ -658,7 +659,7 @@ function Receipt({ // Label each real line item from the catalogue, and price it from Paddle. const base = plan?.base_limits.max_servers ?? 0; const extra = base === -1 ? 0 : Math.max(0, choice.servers - base); - const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier); + const rows = rowsForPlan(options.catalogue, dep, choice.tier); const idFor = (predicate: (r: CatalogueRow) => boolean) => { const row = rows.find(predicate); return row?.price_ids?.[options.env]?.[choice.term] ?? ""; diff --git a/adminsite/app/(staff)/staff/catalogue/page.tsx b/adminsite/app/(staff)/staff/catalogue/page.tsx deleted file mode 100644 index 82f6a5c..0000000 --- a/adminsite/app/(staff)/staff/catalogue/page.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PageHeader } from "@/components/PageHeader"; -import { PageFrame } from "@/components/PageFrame"; -import { Panel } from "@/components/Panel"; -import { TBody, TD, TH, THead, TR, Table } from "@/components/Table"; -import { api, type CatalogueRow, type Term } from "@/lib/api"; - -const ENVS = ["sandbox", "production"] as const; - -/* Self-hosted sells annual only, so the monthly cell is not rendered for it - * rather than rendered and rejected. The backend refuses one either way; this is - * so nobody types into a field that cannot be saved. */ -function termsFor(deployment: string): Term[] { - return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; -} - -function componentLabel(r: CatalogueRow): string { - if (r.kind === "base") return "Base fee"; - if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`; - return `Feature: ${r.feature_key}`; -} - -function rowKey(r: CatalogueRow): string { - return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/"); -} - -export default function CataloguePage() { - const qc = useQueryClient(); - const { data: rows = [], isLoading } = useQuery({ - queryKey: ["staff", "catalogue"], - queryFn: api.staff.catalogue, - }); - const [drafts, setDrafts] = useState>({}); - - const save = useMutation({ - mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r), - onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }), - }); - - const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`))); - - return ( -
- - -

- A component with no price ID is free. A feature with no price is a - toggle a customer may take at no charge; giving it a price here is - all it takes to start charging for it. -

-

- Free is priced by nothing and has no rows. That absence is what - keeps it outside Paddle. -

-

- Changing a price affects the next checkout only. It cannot touch an - issued licence. -

- - } - > - {isLoading ? ( -

Loading…

- ) : ( -
- {groups.map((g) => { - const [deployment, tier] = g.split("/"); - const terms = termsFor(deployment); - return ( - - - - - - {ENVS.map((env) => - terms.map((t) => ( - - )), - )} - - - - {rows - .filter( - (r) => - r.deployment === deployment && - r.tier === tier, - ) - .map((r) => { - const k = rowKey(r); - const ids = drafts[k] ?? r.price_ids ?? {}; - const dirty = - JSON.stringify(ids) !== - JSON.stringify(r.price_ids ?? {}); - return ( - - - {ENVS.map((env) => - terms.map((t) => ( - - )), - )} - - - ); - })} - -
Component - {env} / {t} - -
{componentLabel(r)} - - setDrafts({ - ...drafts, - [k]: { - ...ids, - [env]: { - ...(ids[ - env - ] ?? {}), - [t]: e - .target - .value, - }, - }, - }) - } - className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none" - /> - - -
-
- ); - })} -
- )} -
-
- ); -} diff --git a/adminsite/app/(staff)/staff/layout.tsx b/adminsite/app/(staff)/staff/layout.tsx index 73bcf1f..98357e5 100644 --- a/adminsite/app/(staff)/staff/layout.tsx +++ b/adminsite/app/(staff)/staff/layout.tsx @@ -7,8 +7,7 @@ const LINKS: NavLink[] = [ { href: "/staff", label: "Operations" }, { href: "/staff/accounts", label: "Accounts" }, { href: "/staff/licenses", label: "Licences" }, - { href: "/staff/plans", label: "Plans" }, - { href: "/staff/catalogue", label: "Catalogue" }, + { href: "/staff/pricing", label: "Pricing" }, { href: "/staff/audit", label: "Audit" }, ]; diff --git a/adminsite/app/(staff)/staff/plans/page.tsx b/adminsite/app/(staff)/staff/plans/page.tsx deleted file mode 100644 index 53e76f3..0000000 --- a/adminsite/app/(staff)/staff/plans/page.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { api, type Deployment, type Plan, type Tier } from "@/lib/api"; -import { ConfirmPlanChange } from "@/components/ConfirmPlanChange"; -import { PageHeader } from "@/components/PageHeader"; -import { Panel } from "@/components/Panel"; - -const SUPPORT_LEVELS = [ - { value: "community", label: "Community" }, - { value: "email_24_5", label: "Email, 24/5" }, - { value: "email_call_24_7", label: "Email + call, 24/7" }, -] as const; - -const LIMIT_FIELDS = [ - { key: "max_servers", label: "Servers" }, - { key: "max_monitors", label: "Monitors" }, - { key: "max_secret_groups", label: "Secret groups" }, - { key: "max_channels", label: "Channels" }, - { key: "audit_retention_days", label: "Audit history (days)" }, -] as const; - -/* - * -1 is Unlimited everywhere in the licence payload, so the form takes it - * literally rather than inventing a checkbox. A staff screen that hides the - * sentinel is a staff screen where nobody can tell whether a plan says - * unlimited or nothing at all. - */ -function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) { - const [draft, setDraft] = useState(plan); - const dirty = JSON.stringify(draft) !== JSON.stringify(plan); - - return ( -
-
- {LIMIT_FIELDS.map((f) => ( - - ))} - -
- - - -

Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.

- - -
- ); -} - -export default function PlansPage() { - const qc = useQueryClient(); - const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans }); - const licenses = useQuery({ - queryKey: ["staff-licenses"], - queryFn: () => api.staff.licenses(), - }); - const [draft, setDraft] = useState(null); - const [saving, setSaving] = useState(null); - - const save = useMutation({ - mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["plans"] }); - setDraft(null); - setSaving(null); - }, - onError: () => setSaving(null), - }); - - const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier); - - return ( -
- - - {draft && original && ( - l.tier === draft.tier && l.deployment === draft.deployment).length} - onConfirm={() => { - setSaving(`${draft.deployment}/${draft.tier}`); - save.mutate(draft); - }} - onCancel={() => setDraft(null)} - /> - )} - - {(["cloud", "self_hosted"] as const).map((deployment: Deployment) => ( -
-

{deployment === "cloud" ? "Cloud" : "Self-Hosted"}

- {(plans.data ?? []) - .filter((p) => p.deployment === deployment) - .map((p) => ( - Not offered : undefined} - > - setDraft(next)} /> - - ))} -
- ))} -
- ); -} diff --git a/adminsite/app/(staff)/staff/pricing/CatalogueSection.tsx b/adminsite/app/(staff)/staff/pricing/CatalogueSection.tsx new file mode 100644 index 0000000..fa2755d --- /dev/null +++ b/adminsite/app/(staff)/staff/pricing/CatalogueSection.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Panel } from "@/components/Panel"; +import { SectionHeading } from "./SectionHeading"; +import { planRows, rowKey, sharedRows } from "@/lib/catalogue"; +import { featureLabel } from "@/lib/features"; +import { api, type CatalogueRow, type Term } from "@/lib/api"; + +const ENVS = ["sandbox", "production"] as const; + +/* A shared row is sold by both deployments, so it holds both terms: the cloud + * checkout takes the monthly price and the self-hosted one never asks for it. A + * plan row offers only the terms its own deployment sells — self-hosted is + * annual only, and the field is not rendered rather than rendered and refused. */ +function termsFor(r: CatalogueRow): Term[] { + if (r.scope === "shared") return ["monthly", "annual"]; + return r.deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; +} + +function componentLabel(r: CatalogueRow): string { + if (r.kind === "base") return `${r.tier === "enterprise" ? "Enterprise" : "Professional"} (${r.deployment === "cloud" ? "Cloud" : "Self-hosted"})`; + if (r.kind === "limit") return "Additional server"; + return featureLabel(r.feature_key ?? ""); +} + +function componentDetail(r: CatalogueRow): string { + if (r.kind === "base") return "The plan's own fee, always quantity 1"; + if (r.kind === "limit") return `Raises ${r.limit_key} by one per unit`; + return `feature · ${r.feature_key}`; +} + +/* + * The coverage ledger: one square per environment and term, filled when that + * cell holds a price ID. + * + * A missing production price is invisible in a grid of text inputs — every cell + * looks like every other until you read twenty-six characters of each. This is + * the one thing staff come to this page to check before a launch, so it reads + * before the IDs do. + */ +function Coverage({ row, terms }: { row: CatalogueRow; terms: Term[] }) { + const cells = ENVS.flatMap((env) => terms.map((t) => ({ env, t, filled: Boolean(row.price_ids?.[env]?.[t]) }))); + const filled = cells.filter((c) => c.filled).length; + return ( + + {cells.map((c) => ( + + ))} + + {filled}/{cells.length} priced + + + ); +} + +function ComponentRow({ row, scopeLabel }: { row: CatalogueRow; scopeLabel: string }) { + const qc = useQueryClient(); + const [draft, setDraft] = useState(null); + const ids = draft ?? row.price_ids ?? {}; + const dirty = JSON.stringify(ids) !== JSON.stringify(row.price_ids ?? {}); + const terms = termsFor(row); + + const save = useMutation({ + mutationFn: () => api.staff.updateCatalogue({ ...row, price_ids: ids }), + onSuccess: () => { + setDraft(null); + qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }); + }, + }); + + const set = (env: string, term: Term, value: string) => + setDraft({ ...ids, [env]: { ...(ids[env] ?? {}), [term]: value } }); + + return ( +
+
+ {componentLabel(row)} + {scopeLabel} + {componentDetail(row)} + +
+ +
+
+ {ENVS.map((env) => ( +
+ + {env} + + + {terms.map((t) => ( + + ))} +
+ ))} +
+
+ + {save.error && {(save.error as Error).message}} +
+
+
+ ); +} + +/* + * The catalogue half of /staff/pricing: every priceable component, grouped by + * what it is rather than by which plan sells it. + */ +export function CatalogueSection() { + const { data: rows = [], isLoading } = useQuery({ + queryKey: ["staff", "catalogue"], + queryFn: api.staff.catalogue, + }); + + const shared = sharedRows(rows); + const bases = planRows(rows); + + return ( +
+ + +
+

An add-on is one Paddle product sold to every paid plan, so its price is typed once. Only the base fee differs by plan, because only the base fee is a different product per plan.

+

A component with no price ID is free — a feature with no price is a toggle a customer may take at no charge. Free is priced by nothing and has no rows at all, which is what keeps it outside Paddle. Changing a price affects the next checkout only; it cannot touch an issued licence.

+
+ + {isLoading ? ( +

Loading…

+ ) : ( +
+ + {shared.map((r) => ( + + ))} + + + {bases.map((r) => ( + + ))} + +
+ )} +
+ ); +} diff --git a/adminsite/app/(staff)/staff/pricing/PlansSection.tsx b/adminsite/app/(staff)/staff/pricing/PlansSection.tsx new file mode 100644 index 0000000..ac9bf41 --- /dev/null +++ b/adminsite/app/(staff)/staff/pricing/PlansSection.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { api, type Deployment, type Plan } from "@/lib/api"; +import { featureDesc, featureLabel, FEATURE_LABEL } from "@/lib/features"; +import { limitLabel } from "@/lib/format"; +import { Button, controlClass } from "@/components/Button"; +import { ConfirmPlanChange } from "@/components/ConfirmPlanChange"; +import { SectionHeading } from "./SectionHeading"; +import { Modal } from "@/components/Modal"; + +const SUPPORT_LEVELS = [ + { value: "community", label: "Community" }, + { value: "email_24_5", label: "Email, 24/5" }, + { value: "email_call_24_7", label: "Email + call, 24/7" }, +] as const; + +const LIMIT_FIELDS = [ + { key: "max_servers", label: "Servers" }, + { key: "max_monitors", label: "Monitors" }, + { key: "max_secret_groups", label: "Secret groups" }, + { key: "max_channels", label: "Channels" }, + { key: "audit_retention_days", label: "Audit history (days)" }, +] as const; + +const FEATURE_KEYS = Object.keys(FEATURE_LABEL); + +const planKey = (p: Plan) => `${p.deployment}/${p.tier}`; + +/* + * The list is tiers, and a tier's settings are behind a button. + * + * Six plans with five number fields, a select, a checkbox and four toggles each + * is forty-odd controls on one screen, and the page it made could not be read + * for the thing it exists to answer: what does each tier give you. The card + * answers that; the modal is where it is changed. + */ +function TierCard({ plan, onOpen }: { plan: Plan; onOpen: () => void }) { + return ( + + ); +} + +/* -1 is Unlimited everywhere in the licence payload, so the form takes it + * literally rather than inventing a checkbox. A staff screen that hides the + * sentinel is a staff screen where nobody can tell whether a plan says + * unlimited or nothing at all. */ +function PlanModal({ plan, onClose, onSave }: { plan: Plan; onClose: () => void; onSave: (next: Plan) => void }) { + const [draft, setDraft] = useState(plan); + const dirty = JSON.stringify(draft) !== JSON.stringify(plan); + + const toggleFeature = (key: string, on: boolean) => + setDraft({ + ...draft, + base_features: on ? [...draft.base_features, key] : draft.base_features.filter((f) => f !== key), + }); + + return ( + +

Applies to licences issued from now on. Issued licences snapshotted their plan and are unaffected.

+ + + + } + > +
+ Base limits +
+ {LIMIT_FIELDS.map((f) => ( + + ))} +
+

−1 is unlimited. A metered dimension starts here and the customer buys upward from it.

+
+ +
+ Base features +
+ {FEATURE_KEYS.map((k) => { + const on = draft.base_features.includes(k); + return ( + + ); + })} +
+

No tier bundles a feature today. Including one here grants it with the plan and removes it from the customer's purchase form.

+
+ +
+ Availability +
+ + +
+
+
+ ); +} + +/* + * The plans half of /staff/pricing. It is a section rather than a page because + * a tier's allowances and a tier's price are one decision made in one sitting, + * and they were two screens with no view showing both. + */ +export function PlansSection() { + const qc = useQueryClient(); + const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans }); + const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() }); + + /* Two pieces of state, not one: `editing` is the plan whose modal is open, + * `confirming` is the edit awaiting the change summary. Collapsing them put + * the confirmation behind the modal it was confirming. */ + const [editing, setEditing] = useState(null); + const [confirming, setConfirming] = useState(null); + + const save = useMutation({ + mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["plans"] }); + setConfirming(null); + }, + }); + + const original = plans.data?.find((p) => p.deployment === confirming?.deployment && p.tier === confirming?.tier); + + return ( +
+ + + {confirming && original && ( + l.tier === confirming.tier && l.deployment === confirming.deployment).length} + onConfirm={() => save.mutate(confirming)} + onCancel={() => setConfirming(null)} + /> + )} + + {(["cloud", "self_hosted"] as const).map((deployment: Deployment) => ( +
+

{deployment === "cloud" ? "Cloud" : "Self-hosted"}

+
+ {(plans.data ?? []) + .filter((p) => p.deployment === deployment) + .map((p) => ( + setEditing(p)} /> + ))} +
+
+ ))} + + {editing && ( + setEditing(null)} + onSave={(next) => { + setEditing(null); + setConfirming(next); + }} + /> + )} +
+ ); +} diff --git a/adminsite/app/(staff)/staff/pricing/SectionHeading.tsx b/adminsite/app/(staff)/staff/pricing/SectionHeading.tsx new file mode 100644 index 0000000..992d9c6 --- /dev/null +++ b/adminsite/app/(staff)/staff/pricing/SectionHeading.tsx @@ -0,0 +1,15 @@ +/* + * The heading that separates the two halves of /staff/pricing. + * + * It is not PageHeader: the page has one of those, and a second title-sized + * heading under it would read as a second page. This is the same mono eyebrow + * idiom the deployment groups use, one level up. + */ +export function SectionHeading({ title, note }: { title: string; note: string }) { + return ( +
+

{title}

+

{note}

+
+ ); +} diff --git a/adminsite/app/(staff)/staff/pricing/page.tsx b/adminsite/app/(staff)/staff/pricing/page.tsx new file mode 100644 index 0000000..3bae550 --- /dev/null +++ b/adminsite/app/(staff)/staff/pricing/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { PageHeader } from "@/components/PageHeader"; +import { CatalogueSection } from "./CatalogueSection"; +import { PlansSection } from "./PlansSection"; + +/* + * Plans and catalogue on one page. + * + * They were two nav entries, and the split asked staff to hold one half in + * their head while looking at the other: a tier's allowances decide what the + * metered component charges for, and the base fee is meaningless without the + * allowance it includes. One page, two sections, in the order the decision is + * made — what a tier grants, then what it costs. + */ +export default function PricingPage() { + return ( +
+ + + +
+ ); +} diff --git a/adminsite/components/Modal.tsx b/adminsite/components/Modal.tsx new file mode 100644 index 0000000..ffac8d0 --- /dev/null +++ b/adminsite/components/Modal.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/* + * A native , not a div with a fixed overlay. + * + * showModal() gives focus trapping, inert background, Escape and the top layer + * for free — all four are things a hand-rolled overlay gets wrong, and the third + * is the one staff will actually reach for. The only wiring needed is keeping + * React state and the element's open state in step, and routing every close — + * Escape, backdrop, button — through one onClose. + */ +export function Modal({ + open, + onClose, + title, + meta, + footer, + children, +}: { + open: boolean; + onClose: () => void; + title: string; + meta?: React.ReactNode; + footer?: React.ReactNode; + children: React.ReactNode; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + if (open && !el.open) el.showModal(); + if (!open && el.open) el.close(); + }, [open]); + + return ( + { + e.preventDefault(); + onClose(); + }} + /* Clicking the backdrop hits the dialog element itself, never a + * child — so this closes on backdrop and not on content. */ + onClick={(e) => { + if (e.target === ref.current) onClose(); + }} + className="w-[min(44rem,94vw)] rounded border border-rule bg-panel p-0 text-ink shadow-lg backdrop:bg-[rgba(4,12,24,0.55)]" + > +
+

{title}

+ {meta && {meta}} + +
+
{children}
+ {footer && } +
+ ); +} diff --git a/adminsite/components/PlanConfigurator.tsx b/adminsite/components/PlanConfigurator.tsx index c008dfa..ad9bf78 100644 --- a/adminsite/components/PlanConfigurator.tsx +++ b/adminsite/components/PlanConfigurator.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api"; +import { rowsForPlan } from "@/lib/catalogue"; import { featureLabel } from "@/lib/features"; export interface PlanChoice { @@ -50,7 +51,7 @@ export default function PlanConfigurator({ ); const plan = available.find((p) => p.tier === value.tier); const rows = useMemo( - () => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier), + () => rowsForPlan(catalogue, deployment, value.tier), [catalogue, deployment, value.tier], ); const featureRows = rows.filter((r) => r.kind === "feature"); diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index 60e07e5..d57c2df 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -8,6 +8,9 @@ * customer-facing and should be shown verbatim). */ +/* catalogue.ts imports only types from here, so this is not a cycle. */ +import { rowsForPlan } from "@/lib/catalogue"; + export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, ""); export class NotConnected extends Error { @@ -176,6 +179,11 @@ export interface Plan { export interface CatalogueRow { kind: "base" | "limit" | "feature"; + /* "plan" rows carry a deployment and tier and belong to that plan alone. + * "shared" rows leave both empty and are sold by every paid plan, which is + * why a price ID is typed once rather than four times. Read them through + * rowsForPlan in lib/catalogue, never by filtering on deployment. */ + scope: "plan" | "shared"; deployment: Deployment; tier: Tier; limit_key?: string; @@ -213,9 +221,7 @@ export function lineItemsFor( const env = opts.env; const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier); if (!plan) return []; - const rows = opts.catalogue.filter( - (r) => r.deployment === deployment && r.tier === choice.tier, - ); + const rows = rowsForPlan(opts.catalogue, deployment, choice.tier); const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? ""; const base = plan.base_limits.max_servers; const items: { priceId: string; quantity: number }[] = []; diff --git a/adminsite/lib/catalogue.ts b/adminsite/lib/catalogue.ts new file mode 100644 index 0000000..76c9faa --- /dev/null +++ b/adminsite/lib/catalogue.ts @@ -0,0 +1,38 @@ +import type { CatalogueRow, Deployment, Tier } from "@/lib/api"; + +/* + * rowsForPlan is the TypeScript half of Go's models.CatalogueFor, and the two + * must change together — the same shape of hazard as web/lib/targets.ts. + * + * A plan sells its own base row plus every shared add-on row. Shared rows leave + * deployment and tier empty, so the filter this replaced — `r.deployment === dep + * && r.tier === tier` — now returns a plan priced by its base fee and nothing + * else. There were five copies of that filter; this is why it is a module. + */ +export function rowsForPlan( + catalogue: CatalogueRow[], + deployment: Deployment, + tier: Tier, +): CatalogueRow[] { + return catalogue.filter( + (r) => r.scope === "shared" || (r.deployment === deployment && r.tier === tier), + ); +} + +/* Every add-on a paid plan can be sold, in one list. The staff catalogue editor + * shows these once; the purchase form reads them per plan through rowsForPlan. */ +export function sharedRows(catalogue: CatalogueRow[]): CatalogueRow[] { + return catalogue.filter((r) => r.scope === "shared"); +} + +/* The base fee rows, which are genuinely one per plan because each is its own + * Paddle product at its own price. */ +export function planRows(catalogue: CatalogueRow[]): CatalogueRow[] { + return catalogue.filter((r) => r.scope !== "shared"); +} + +/* A stable identity for a row, used as a React key and as the draft key in the + * staff editor. Mirrors the natural key the API addresses a row by. */ +export function rowKey(r: CatalogueRow): string { + return [r.scope ?? "plan", r.deployment ?? "", r.tier ?? "", r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/"); +} diff --git a/adminsite/next.config.ts b/adminsite/next.config.ts index 20988d0..2afca95 100644 --- a/adminsite/next.config.ts +++ b/adminsite/next.config.ts @@ -8,6 +8,14 @@ import type { NextConfig } from "next"; */ const nextConfig: NextConfig = { output: "standalone", + /* Plans and catalogue became one page. Both old paths are bookmarked in + * staff browsers, so they redirect rather than 404. */ + async redirects() { + return [ + { source: "/staff/plans", destination: "/staff/pricing", permanent: true }, + { source: "/staff/catalogue", destination: "/staff/pricing", permanent: true }, + ]; + }, }; export default nextConfig;