commerce: a licence answers from the row, not the price list
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CheckEntitlement asked @hanzo/plans which products a subscriber's tier licenses. The catalog lists what is ON SALE TODAY, so a tier retired at 1.4.5 resolves to 404 -> found=false -> continue -> Active:false. That is a definitive "not entitled", indistinguishable from a real refusal, for someone commerce is still charging. On the team product it reaches cloud.Refuse(ReasonUnpaid): a paying subscriber gets a 402 for a licence they hold. This is the licensing half of the cut Paid already made for the paywall. Ask what you bought, not what is for sale. The tier's authority row survives retirement — that is what Status is for — and commerce v1.50.9 persists the licensing block on it and backfills the rows archived before the field existed, so the row can now answer. So the resolver reads the row. Features are derived from the block it carries; only the token spelling lives in Go, and a spelling is not a policy — the plan->product decision is the row's. Scope, measured rather than assumed: of the thirteen retired slugs only plus and team-max ever licensed anything, both ["team"]. developer, custom and the world-*/social-* lines carried no licensing block even while on sale, so nothing regressed for them and nothing is invented for them here. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+49
-17
@@ -3,14 +3,14 @@
|
||||
// client.go is the in-process inter-subsystem commerce client — the REAL
|
||||
// implementation of cloud's types.CommerceClient, absorbed here from the retired
|
||||
// in-process stub that used to fail closed on entitlement. It answers cloud's licensing/entitlements tier
|
||||
// with DIRECT Go calls into the embedded commerce datastore (subscriptions) plus
|
||||
// the @hanzo/plans vocabulary (plan → license features) — no HTTP hop, no network.
|
||||
// with DIRECT Go calls into the embedded commerce datastore — the org's
|
||||
// subscriptions, and the plan authority row each one names — no HTTP hop, no network.
|
||||
//
|
||||
// MONEY-SAFETY. CheckEntitlement NEVER fabricates a grant. It returns Active:true
|
||||
// ONLY when a real active, unexpired subscription in the org's own datastore
|
||||
// namespace holds a plan tier whose @hanzo/plans license-features actually name the
|
||||
// product. Any machinery it cannot resolve (commerce not co-resident, org not
|
||||
// resolvable, subscription query error, plans vocabulary unavailable) returns an
|
||||
// namespace holds a plan tier whose OWN authority row licenses the product. Any
|
||||
// machinery it cannot resolve (commerce not co-resident, org not
|
||||
// resolvable, subscription query error, plan authority unreadable) returns an
|
||||
// ERROR — the entitlements gate treats an erroring client as "cannot verify ⇒ 503",
|
||||
// the specified secure default, so an unverifiable product is never enabled. A
|
||||
// clean "resolved, but no plan licenses this product" is a real Active:false answer
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/hanzoai/cloud/types"
|
||||
commercemod "github.com/hanzoai/commerce"
|
||||
"github.com/hanzoai/commerce/datastore"
|
||||
commerceplan "github.com/hanzoai/commerce/models/plan"
|
||||
"github.com/hanzoai/commerce/models/subscription"
|
||||
commerceorg "github.com/hanzoai/commerce/pkg/org"
|
||||
)
|
||||
@@ -116,11 +117,21 @@ func (c *inProcessClient) CheckEntitlement(ctx context.Context, orgID, productID
|
||||
return nil, fmt.Errorf("commerce.CheckEntitlement: query subscriptions for org %q: %w", orgID, err)
|
||||
}
|
||||
|
||||
// 3. For each active, unexpired subscription resolve its plan tier's flat license
|
||||
// features from @hanzo/plans (the single source of truth) and check whether it
|
||||
// licenses productID. Product scoping is the presence of the
|
||||
// "licensing.product:<id>" token toLicenseFeatures derives from a plan's
|
||||
// licensing.product_ids, so only a plan that actually names the product grants it.
|
||||
// 3. For each active, unexpired subscription resolve what its plan tier LICENSES
|
||||
// from the tier's OWN authority row, and check whether that names productID.
|
||||
// Product scoping is the presence of the "licensing.product:<id>" token, so
|
||||
// only a plan that actually names the product grants it.
|
||||
//
|
||||
// The row, not the catalog. The catalog lists what is ON SALE TODAY: ask it
|
||||
// about a tier that has since been retired and it answers "licenses nothing",
|
||||
// definitively, so the answer reads as a refusal rather than as the missing
|
||||
// record it is — and the subscriber, who is still being charged, loses every
|
||||
// product they bought. Commerce keeps the row resolvable precisely so that
|
||||
// cannot happen ("retiring a tier stops new sales; it never strands a
|
||||
// subscriber"), and the row now carries its licensing block for the same
|
||||
// reason it carries Category and Price. This is the licensing half of the cut
|
||||
// Paid already makes for the paywall: classify on what you bought, not on
|
||||
// what is for sale.
|
||||
now := time.Now()
|
||||
want := "licensing.product:" + productID
|
||||
var bestPlan string
|
||||
@@ -135,20 +146,28 @@ func (c *inProcessClient) CheckEntitlement(ctx context.Context, orgID, productID
|
||||
if slug == "" {
|
||||
continue // no resolvable plan tier on this sub — cannot grant from it
|
||||
}
|
||||
_, features, found, ferr := plan.LicenseEntitlement(ctx, slug)
|
||||
if ferr != nil {
|
||||
// MACHINERY failure (plans vocabulary unavailable): cannot resolve features
|
||||
// ⇒ cannot verify ⇒ fail closed. Never deny-by-guess on an outage.
|
||||
return nil, fmt.Errorf("commerce.CheckEntitlement: resolve plan %q features: %w", slug, ferr)
|
||||
row, found, rerr := tier(ctx, slug)
|
||||
if rerr != nil {
|
||||
// MACHINERY failure (the plan authority is unreadable): cannot resolve the
|
||||
// tier ⇒ cannot verify ⇒ fail closed. Never deny-by-guess on an outage.
|
||||
return nil, fmt.Errorf("commerce.CheckEntitlement: resolve plan %q: %w", slug, rerr)
|
||||
}
|
||||
if !found {
|
||||
// Unknown plan tier — not in the catalog, so it licenses nothing. Skip
|
||||
// (conservative: never grants) and keep scanning the org's other subs.
|
||||
// No authority row for this tier at all — nothing to read a licence from.
|
||||
// Skip (conservative: never grants) and keep scanning the org's other subs.
|
||||
continue
|
||||
}
|
||||
if bestPlan == "" {
|
||||
bestPlan = slug
|
||||
}
|
||||
if row.Licensing == nil {
|
||||
continue // a tier that licenses nothing licenses nothing.
|
||||
}
|
||||
features := plan.Tokens(plan.Licence{
|
||||
Products: row.Licensing.Products,
|
||||
Apps: row.Licensing.Apps,
|
||||
Features: row.Licensing.Features,
|
||||
})
|
||||
if containsFeature(features, want) {
|
||||
return &types.LicenseEntitlement{
|
||||
ProductID: productID,
|
||||
@@ -166,6 +185,19 @@ func (c *inProcessClient) CheckEntitlement(ctx context.Context, orgID, productID
|
||||
return &types.LicenseEntitlement{ProductID: productID, Active: false, Plan: bestPlan}, nil
|
||||
}
|
||||
|
||||
// tier resolves the plan authority row for slug. The authority is platform-global
|
||||
// (commerce models/plan, the "system" namespace) — the SAME rows the boot seed
|
||||
// reconciles, admin.hanzo.ai edits and the charge path prices from — so there is one
|
||||
// answer to "what is this tier", and it keeps answering after the tier is retired.
|
||||
func tier(ctx context.Context, slug string) (*commerceplan.Plan, bool, error) {
|
||||
p := commerceplan.New(commerceplan.AuthorityDB(ctx))
|
||||
ok, err := p.Query().Filter("Slug=", slug).Get()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func containsFeature(features []string, want string) bool {
|
||||
for _, f := range features {
|
||||
if f == want {
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
"github.com/hanzoai/cloud/apps/commerce"
|
||||
"github.com/hanzoai/cloud/apps/plan"
|
||||
commercemod "github.com/hanzoai/commerce"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
"github.com/hanzoai/commerce/billing/grant"
|
||||
"github.com/hanzoai/commerce/datastore"
|
||||
commerceplan "github.com/hanzoai/commerce/models/plan"
|
||||
commerceorg "github.com/hanzoai/commerce/pkg/org"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -42,6 +44,13 @@ func (fakeCatalog) Lookup(slug string) *grant.CatalogPlan {
|
||||
if slug == "max" {
|
||||
return &grant.CatalogPlan{Slug: "max", Name: "Max", Description: "Max tier", PriceCents: 20000, Currency: "usd"}
|
||||
}
|
||||
// "plus" is a RETIRED tier: @hanzo/plans stopped publishing it at 1.4.5. It is
|
||||
// here because a subscription on it was opened while it WAS on sale, and that
|
||||
// subscriber is still being charged — which is exactly the case the entitlement
|
||||
// resolver must keep answering.
|
||||
if slug == "plus" {
|
||||
return &grant.CatalogPlan{Slug: "plus", Name: "Plus", Description: "Retired tier", PriceCents: 10000, Currency: "usd"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -138,6 +147,55 @@ func TestInProcessClient(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// A subscriber on a RETIRED tier must still resolve the products that tier
|
||||
// licensed. The catalog cannot answer — it lists what is on sale today, and this
|
||||
// tier is not — so the answer has to come from the tier's own authority row,
|
||||
// which commerce keeps resolvable after retirement and now backfills the
|
||||
// licensing block onto. Without that the org is refused a product it pays for.
|
||||
t.Run("retired_tier_still_licenses_its_products", func(t *testing.T) {
|
||||
const org = "plusco"
|
||||
|
||||
// Production state: the row was archived back when a plan carried no
|
||||
// licensing block at all, so it has none.
|
||||
adb := commerceplan.AuthorityDB(ctx)
|
||||
row := commerceplan.New(adb)
|
||||
row.Slug, row.Category, row.Price = "plus", "personal", 10000
|
||||
row.Status, row.Managed = commerceplan.StatusArchived, true
|
||||
if err := row.Create(); err != nil {
|
||||
t.Fatalf("create archived plus row: %v", err)
|
||||
}
|
||||
|
||||
seedActiveGrant(t, ctx, org, "plus")
|
||||
|
||||
// Before the boot seed backfills it, the row cannot say what it licensed —
|
||||
// which is precisely the live defect.
|
||||
if ent, err := client.CheckEntitlement(ctx, org, "team"); err != nil {
|
||||
t.Fatalf("CheckEntitlement: %v", err)
|
||||
} else if ent.Active {
|
||||
t.Fatalf("un-backfilled archived row must not grant; got %+v", ent)
|
||||
}
|
||||
|
||||
// The boot seed reconciles the catalog and backfills what retired tiers
|
||||
// licensed when they were last on sale.
|
||||
if _, _, err := commercebilling.SeedPlans(ctx); err != nil {
|
||||
t.Fatalf("SeedPlans: %v", err)
|
||||
}
|
||||
|
||||
ent, err := client.CheckEntitlement(ctx, org, "team")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckEntitlement: %v", err)
|
||||
}
|
||||
if !ent.Active {
|
||||
t.Fatalf("a subscriber on the retired %q tier must still hold its team licence; got %+v", "plus", ent)
|
||||
}
|
||||
if ent.Plan != "plus" {
|
||||
t.Errorf("Plan = %q, want plus", ent.Plan)
|
||||
}
|
||||
if !hasFeature(ent.Features, "licensing.product:team") {
|
||||
t.Errorf("Features %v missing licensing.product:team", ent.Features)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entitled_org_but_unlicensed_product_is_not_active_no_error", func(t *testing.T) {
|
||||
const org = "maxco2"
|
||||
seedActiveGrant(t, ctx, org, "max")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package plan
|
||||
|
||||
// licence.go writes a tier's licensing facts down the way the engine reads them.
|
||||
//
|
||||
// The facts themselves — which products, app builds and engine capabilities a tier
|
||||
// grants — live on the commerce plan ROW. They used to be read from the @hanzo/plans
|
||||
// catalog, and that is the defect: the catalog lists what is ON SALE TODAY, so a tier
|
||||
// that has been retired resolves to nothing, definitively, and a subscriber who is
|
||||
// still being charged is refused the products they bought. The row keeps its answer
|
||||
// after retirement, which is the same reason Paid classifies on the row's category
|
||||
// and price rather than looking the slug up in the price list.
|
||||
//
|
||||
// Only the SPELLING of the tokens is here, and a spelling is not a policy: the
|
||||
// plan→product decision belongs to the row, and this states it in the flat vocabulary
|
||||
// the engine verifies (@hanzo/plans entitlements.mjs#toLicenseFeatures).
|
||||
|
||||
import "slices"
|
||||
|
||||
// Licence is the licensing facts of one tier — the subset of a commerce plan row
|
||||
// this package needs to state the rule without importing the commerce models, for
|
||||
// the same reason Tier exists.
|
||||
type Licence struct {
|
||||
// Products are the commerce SKUs the tier entitles ("engine", "team", …).
|
||||
Products []string
|
||||
// Apps are the engine app builds it licenses ("hanzo", "lux", "zoo").
|
||||
Apps []string
|
||||
// Features are engine capability tokens granted verbatim ("inference", …).
|
||||
Features []string
|
||||
}
|
||||
|
||||
// Tokens returns l as the flat license-feature list: engine features verbatim, plus
|
||||
// one namespaced token per licensed app and product. Sorted and deduplicated so the
|
||||
// same licence always produces the same list, and so a token issued from it is
|
||||
// byte-stable across processes.
|
||||
//
|
||||
// A tier that licenses nothing yields an empty list — never a token, never a grant.
|
||||
func Tokens(l Licence) []string {
|
||||
out := make([]string, 0, len(l.Features)+len(l.Apps)+len(l.Products))
|
||||
out = append(out, l.Features...)
|
||||
for _, a := range l.Apps {
|
||||
out = append(out, "licensing.app:"+a)
|
||||
}
|
||||
for _, p := range l.Products {
|
||||
out = append(out, "licensing.product:"+p)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ require (
|
||||
github.com/google/go-github/v52 v52.0.0
|
||||
github.com/hanzoai/account v0.2.1
|
||||
github.com/hanzoai/cek v0.2.3
|
||||
github.com/hanzoai/commerce v1.50.8
|
||||
github.com/hanzoai/commerce v1.50.9
|
||||
github.com/hanzoai/decimal v0.1.2
|
||||
github.com/hanzoai/flags/go v0.1.1
|
||||
github.com/hanzoai/go-openai v1.41.0
|
||||
|
||||
@@ -1005,6 +1005,8 @@ github.com/hanzoai/cek v0.2.3 h1:wOVav3abWAWiIyqTr9pKK/b4PjUCT1j3mdU5LzKpxSQ=
|
||||
github.com/hanzoai/cek v0.2.3/go.mod h1:T9c9qr9x+0kHsk0J57KElY7l2jgWF08onbxZ3ck5nxM=
|
||||
github.com/hanzoai/commerce v1.50.8 h1:6ZBPE6eAZtVMkzKIdG66eHIjTB0G6luuZJfRLJGNkg4=
|
||||
github.com/hanzoai/commerce v1.50.8/go.mod h1:TtxF3nlzmKav9XCVcNF2h03jFPukzTLemTuG9my38jY=
|
||||
github.com/hanzoai/commerce v1.50.9 h1:icSXpLlqZJ0CQkf2Yn48huiSqyDHzDOxQNUxh4v2TRE=
|
||||
github.com/hanzoai/commerce v1.50.9/go.mod h1:TtxF3nlzmKav9XCVcNF2h03jFPukzTLemTuG9my38jY=
|
||||
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
|
||||
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
|
||||
github.com/hanzoai/dashscopego v0.6.0 h1:sLUepwcnVajaDgzlgfuJWkZblBg9UIalkpFUjXHx0Fg=
|
||||
|
||||
Reference in New Issue
Block a user