plan

package
v1.49.49 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Index

Constants

View Source
const (
	StatusActive   = "active"   // sold and listed publicly
	StatusDraft    = "draft"    // exists, not listed — being written
	StatusArchived = "archived" // retired, kept for history
)

Plan lifecycle, on the Shopify product model. A plan is never deleted to stop selling it — it is archived, so invoices and subscriptions that recorded the slug still resolve.

View Source
const EnvelopeKey = "envelope"

EnvelopeKey is the ONE Metadata key the display envelope is packed under. One key holding one JSON string, so it round-trips cleanly through the Map[string]interface{} the ORM deserializes Metadata into — a string survives, where a nested struct would degrade to map[string]interface{} and lose the typed Limits shape.

View Source
const Namespace = "system"

Namespace is the platform-global namespace the plan authority lives in — the SAME "system" namespace the product catalog uses. Subscription and DNS plan pricing is platform-wide and governed centrally by admin.hanzo.ai, NOT per tenant, so every authoritative touch of a plan (the boot seed, the SuperAdmin CRUD, GET /v1/billing/plans, and resolveSubscriptionPlan → the internal-ledger charge) resolves HERE. Kept identical to catalog's "system" so one platform namespace holds all cross-tenant CMS/pricing data.

Variables

View Source
var IgnoreFieldMismatch = datastore.IgnoreFieldMismatch

Functions

func AuthorityDB added in v1.49.12

func AuthorityDB(ctx context.Context) *datastore.Datastore

AuthorityDB returns a datastore scoped (via context — the only namespacing the SQL layer honors) to the platform plan-authority namespace. Pass the request context so tracing/deadlines flow through; a nil context degrades to Background (bootstrap/CLI callers).

func Query

func Seed added in v1.49.12

func Seed(db *datastore.Datastore, rows []*Plan) (created, corrected int, err error)

Seed reconciles the plan authority to the published catalog. It is safe to run on EVERY boot (cheap: one point query per row; writes only when needed) and is the ONE seeder.

The rule is simply: the published catalog decides, an admin edit overrides.

  • MISSING → create it.
  • present, ADMIN-EDITED → LEAVE. A human decided this value; the catalog does not get to overwrite it. That is the whole point of an editable authority.
  • present, otherwise → RECONCILE its typed money fields + display envelope to the catalog. This covers both a row the seed itself wrote earlier (which it previously could never correct — see AdminEdited) and a partial row written by a subscription-flow path such as the bundle expansion, which once wrote Price=0 and under-charged.

Rows the catalog NO LONGER publishes are ARCHIVED, not deleted and not left listed. A tier that stops being published must stop being sold, or retiring it requires a second manual step that is easy to forget and easy to get wrong; archiving keeps the row resolvable for invoices and renewals that recorded the slug. An admin-edited row is never archived this way — a plan a human added on purpose is not "missing from the catalog", it is theirs.

Idempotent: a second run finds every row already equal to the catalog and writes nothing. seedMu makes the per-slug check-then-write atomic in-process. Returns (created, corrected) where corrected counts reconciles + archives.

Types

type Limits added in v1.49.30

type Limits struct {
	// Subscription (API) limits
	RequestsPerMinute *int `json:"requestsPerMinute,omitempty"`
	TokensPerMinute   *int `json:"tokensPerMinute,omitempty"`
	FreeCredit        *int `json:"freeCredit,omitempty"`
	MaxMembers        *int `json:"maxMembers,omitempty"`

	// MinSeats is the minimum billable seat count for per-seat plans
	// (price_ref.recurring.per_seat) — the ONE canonical home for seat minimums.
	// Billing charges at least this many seats.
	MinSeats *int `json:"minSeats,omitempty"`

	// TeamGuests is the back-compat source for the team.guests entitlement:
	// max invited guests on the holder's hanzo.team workspace (-1 = unlimited).
	TeamGuests *int `json:"teamGuests,omitempty"`

	// IncludedCreditUsd is a legacy alias (entitlements["commerce.included_credit_usd"]).
	// IncludedCloudCredits(+PerUser) is the cloud allowance the monthly allotment
	// grants (the canonical cloud.included_credits_usd entitlement).
	IncludedCreditUsd           *int `json:"includedCreditUsd,omitempty"`
	IncludedCloudCredits        *int `json:"includedCloudCredits,omitempty"`
	IncludedCloudCreditsPerUser *int `json:"includedCloudCreditsPerUser,omitempty"`

	// DNS limits
	Zones          *int `json:"zones,omitempty"`
	RecordsPerZone *int `json:"recordsPerZone,omitempty"`
	QueriesPerDay  *int `json:"queriesPerDay,omitempty"`
}

Limits is a plan's published allowance block — rate ceilings, seat floors and included credit. It lives HERE, with the plan, because it is plan data: the catalog publishes it and GET /v1/billing/plans serves it verbatim. api/billing aliases this type rather than declaring a second copy.

type Plan

type Plan struct {
	mixin.Model[Plan]

	// Unique human readable id
	Slug string `json:"slug"`
	// Internal id
	SKU string `json:"sku"`

	// Human readable name
	Name        string `json:"name"`
	Description string `json:"description"`

	// Category is the plan family ("personal"/"team"/"enterprise"/"world"/
	// "social"/"dns"); GET /v1/billing/plans?category= filters on it.
	Category string `json:"category"`

	Price currency.Cents `json:"price"`
	// PriceAnnual is the per-month price when billed annually, in cents — the
	// authoritative annual price (previously derived at the read edge from the
	// embed). Like Price it is a TYPED money field, never an untyped Metadata
	// value, so a stored/spoofed plan copy can never inflate it.
	PriceAnnual     currency.Cents `json:"priceAnnual"`
	Currency        currency.Type  `json:"currency"`
	Interval        Interval       `json:"interval"`
	IntervalCount   int            `json:"intervalCount"`
	TrialPeriodDays int            `json:"trialPeriodDays"`

	// PerSeat marks a plan billed per seat (catalog price_ref.recurring.per_seat):
	// invoices charge Price × subscription quantity, floored at 1.
	PerSeat bool `json:"perSeat"`

	// ContactSales marks a custom / "contact sales" plan whose price is NULL, not
	// $0 — the ONE way to preserve the free($0)-vs-custom(null) distinction while
	// keeping Price a typed, non-nullable Cents (mirrors the embed's staticPlan).
	// A null-priced plan is stored Price=0 + ContactSales=true; a free plan is
	// Price=0 + ContactSales=false. Never coerce null→0 WITHOUT this flag, or a
	// custom plan spuriously reads as a $0 charge.
	ContactSales bool `json:"contactSales,omitempty"`
	// Popular flags the highlighted tier within a category (display only).
	Popular bool `json:"popular,omitempty"`

	// AdminEdited marks a row whose CURRENT VALUE WAS DECIDED BY A HUMAN through
	// the SuperAdmin CRUD. It is the discriminator Managed could never be.
	//
	// Managed is set by BOTH the seed and the admin CRUD, so it carries two
	// different facts under one name and the seed cannot tell its own prior
	// output from a deliberate price edit. The consequence was that the seed
	// could never correct itself: once a row existed it was frozen forever, so
	// publishing a new catalog created the plans that were missing and left every
	// plan that had changed at its old price — a catalog half-new and half-stale,
	// which is worse than either.
	//
	// Only api/plan's Create/Update sets this. Seed reconciles anything else to
	// the published catalog, which is exactly the rule everyone assumed already
	// held: the package is the source, an admin edit is an override.
	AdminEdited bool `json:"adminEdited,omitempty"`

	// Managed marks a row OWNED by the seed or the SuperAdmin CRUD (authoritative).
	// The corrective boot seed force-corrects UNMANAGED rows — accidental partials
	// written by a subscription-flow path (Red: the bundle expansion wrote a
	// Price=0, envelope-less row that under-charged a direct sub) — to the embed,
	// and LEAVES managed rows so an admin price edit is preserved. One flag
	// distinguishes "authoritative" from "accidental", closing the divergence.
	Managed bool `json:"managed,omitempty"`

	// Status is the row's lifecycle, on the Shopify product model: a plan is
	// ACTIVE (sold and listed), DRAFT (exists, not yet listed) or ARCHIVED
	// (retired, kept). Only ACTIVE rows reach the public catalog.
	//
	// It exists because without it "stop selling this" and "destroy this" were the
	// SAME operation. The public read returned every stored row, so the only way to
	// unlist a plan was DELETE — which takes the row's history with it and orphans
	// any subscription that recorded the slug. Retiring a tier should never be
	// destructive; archiving keeps the row resolvable for invoices and renewals
	// that already reference it.
	//
	// EMPTY MEANS ACTIVE, deliberately. Every row written before this field existed
	// has no value for it, and a zero-value that meant "draft" would unlist the
	// entire catalog on deploy. Use Listed() rather than comparing this directly.
	Status string `json:"status,omitempty"`

	// The DISPLAY ENVELOPE: presentation, not money. The typed columns above are
	// what charges; these are what the pricing page renders.
	//
	// They are JSON-visible and datastore-SKIPPED on purpose. JSON-visible so the
	// model's shape IS the wire shape — the admin CRUD decodes a request body
	// straight onto a Plan, so whatever GET /v1/billing/plans emits, PUT
	// /v1/plans/entries/:slug accepts. Before they existed the envelope lived ONLY
	// inside Metadata as a packed JSON string, so an admin PUT carrying `features`
	// set the price and silently discarded the copy: a tier could be repriced but
	// never re-described, which is how a plan comes to be sold at one price while
	// its own feature list quotes another.
	//
	// Datastore-skipped because they still PERSIST packed into Metadata_ (Save
	// packs, Load unpacks) — one storage location, unchanged on disk, so existing
	// rows keep working and there is no migration.
	Features   []string `json:"features,omitempty" datastore:"-"`
	Bundles    []string `json:"bundles,omitempty" datastore:"-"`
	IncludedIn []string `json:"includedIn,omitempty" datastore:"-"`
	Limits     *Limits  `json:"limits,omitempty" datastore:"-"`

	// Metadata carries anything else a plan needs to hold, plus the packed
	// envelope above. Metadata_ MUST be datastore:",noindex" (persisted), NOT "-"
	// (skipped): with "-" the whole blob Save()s but never round-trips, so a DB
	// read drops limits/minSeats/features (prod: team.minSeats served null).
	// Mirrors catalogentry exactly — the persisted-blob pattern is the one way.
	Metadata  Map    `json:"metadata,omitempty" datastore:"-"`
	Metadata_ string `json:"-" datastore:",noindex"`

	Ref refs.EcommerceRef `json:"ref,omitempty"`
}

func Fake

func Fake(db *datastore.Datastore) *Plan

func New

func New(db *datastore.Datastore) *Plan

func (*Plan) Listed added in v1.49.29

func (p *Plan) Listed() bool

Listed reports whether this plan is ON SALE — the ONE predicate behind both halves of that, because they are one decision:

  • it appears in the public catalog (GET /v1/billing/plans), and
  • a NEW subscription may open on it (the three purchase entrypoints).

Resolving a plan is a DIFFERENT question and is deliberately not gated here: resolveSubscriptionPlan answers "which row is this" and must keep answering for an archived plan, or a renewal or invoice on a retired tier would fail to price itself. Retiring a tier stops new sales; it never strands a subscriber.

An empty Status counts as active: every row written before Status existed has no value for it, and treating that as unlisted would blank the catalog the moment this ships. Only an explicit draft/archived hides a row.

func (*Plan) Load

func (p *Plan) Load(ps []datastore.Property) (err error)

func (*Plan) Save

func (p *Plan) Save() (ps []datastore.Property, err error)

func (*Plan) Validator

func (p *Plan) Validator() *val.Validator

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL