mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
GET /v1/billing/plans served priceAnnual 0 for advisory, dedicated and enterprise, where the catalog states null: the wire type was a plain int64, so an absent annual price became a $0 one, which on a plan that charges advertises a free year of it. PlanView.PriceAnnual is now *int64 and the embed keeps the catalog's null. The row keeps money non-nullable and stores "none" as zero, as it stores a sales call's price beside ContactSales; annualOf reads a zero annual price on a plan that charges back as null, so the seeded authority the endpoint reads answers what the embed does. Charging is unchanged: a year still bills AnnualTotal and is refused where that is 0. TestAnnualPriceIsTheCatalogs marshals the embed and the seeded authority through ReadPlans and requires every plan's priceAnnual to be the catalog's, null included. Co-authored-by: Hanzo Dev <[email protected]>
198 lines
7.4 KiB
Go
198 lines
7.4 KiB
Go
package billing
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/plan"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/util/nscontext"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
func minSeatsOf(p *staticPlan) int {
|
|
if p.Limits == nil || p.Limits.MinSeats == nil {
|
|
return -1
|
|
}
|
|
return *p.Limits.MinSeats
|
|
}
|
|
|
|
// TestProdPath_SeedReadbackAllFields is the prod-path guard the in-memory
|
|
// TestSeededPricesEqualEmbed missed: it seeds via the REAL path into a real
|
|
// datastore, then reads EVERY plan back through the SAME projections prod serves
|
|
// (planAuthorityRows→staticPlanFromModel for GET /v1/billing/plans, and
|
|
// resolveSubscriptionPlan for the charge) and asserts Price/PriceAnnual/Category/
|
|
// minSeats/perSeat/limits == embed for ALL 20. It FAILS on the Metadata_
|
|
// round-trip bug (envelope/minSeats dropped) and passes once persisted.
|
|
func TestProdPath_SeedReadbackAllFields(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
|
|
created, _, err := SeedPlans(c)
|
|
if err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if created != len(catalog) {
|
|
t.Fatalf("seed created %d, want %d", created, len(catalog))
|
|
}
|
|
|
|
rows, ok := planAuthorityRows(c)
|
|
if !ok {
|
|
t.Fatal("authority empty after seed")
|
|
}
|
|
list := map[string]staticPlan{}
|
|
for _, r := range rows {
|
|
list[r.Slug] = r
|
|
}
|
|
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
|
|
|
|
for i := range catalog {
|
|
e := catalog[i]
|
|
g, present := list[e.Slug]
|
|
if !present {
|
|
t.Errorf("%s: missing from ListPlans", e.Slug)
|
|
continue
|
|
}
|
|
// Display projection (GET /v1/billing/plans).
|
|
if g.Price != e.Price || !sameCents(g.PriceAnnual, e.PriceAnnual) {
|
|
t.Errorf("%s: LIST price=%d/%s want %d/%s", e.Slug, g.Price, centsText(g.PriceAnnual), e.Price, centsText(e.PriceAnnual))
|
|
}
|
|
if g.Category != e.Category {
|
|
t.Errorf("%s: LIST category=%q want %q", e.Slug, g.Category, e.Category)
|
|
}
|
|
if g.PerSeat != e.PerSeat {
|
|
t.Errorf("%s: LIST perSeat=%v want %v", e.Slug, g.PerSeat, e.PerSeat)
|
|
}
|
|
if minSeatsOf(&g) != minSeatsOf(&e) {
|
|
t.Errorf("%s: LIST minSeats=%d want %d (envelope must round-trip)", e.Slug, minSeatsOf(&g), minSeatsOf(&e))
|
|
}
|
|
if (g.Limits == nil) != (e.Limits == nil) {
|
|
t.Errorf("%s: LIST limits nil=%v want nil=%v", e.Slug, g.Limits == nil, e.Limits == nil)
|
|
}
|
|
// Charge path.
|
|
rp, rerr := resolveSubscriptionPlan(orgDB, e.Slug)
|
|
if rerr != nil {
|
|
t.Errorf("%s: resolve err %v", e.Slug, rerr)
|
|
continue
|
|
}
|
|
if int64(rp.Price) != e.Price || int64(rp.PriceAnnual) != e.annual() {
|
|
t.Errorf("%s: RESOLVE price=%d/%d want %d/%d (CHARGE)", e.Slug, rp.Price, rp.PriceAnnual, e.Price, e.annual())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProdPath_CorrectsPreexistingBadRows proves fix #3: the corrective seed
|
|
// repairs PRE-EXISTING unmanaged partial rows on the (persistent) store — not
|
|
// just an empty DB. It plants the exact prod corruption (world-pro Price=0 like
|
|
// the bundle write; a row with no Category; team with no minSeats), then runs
|
|
// SeedPlans and asserts the charge + display are corrected to the embed.
|
|
func TestProdPath_CorrectsPreexistingBadRows(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
adb := plan.AuthorityDB(c)
|
|
|
|
plant := func(slug string, price int64, category string) {
|
|
p := plan.New(adb)
|
|
p.Slug = slug
|
|
p.Price = currency.Cents(price)
|
|
p.Category = category
|
|
p.Managed = false // written by a subscription-flow path, not the seed
|
|
if err := p.Create(); err != nil {
|
|
t.Fatalf("plant %s: %v", slug, err)
|
|
}
|
|
}
|
|
// The prod corruption, re-planted on CURRENT slugs: a subscription-flow path
|
|
// wrote a $0 or category-less row where the catalog has a real one.
|
|
plant("dev", 0, "") // wrote $0 (the under-charge)
|
|
plant("max-5x", 0, "") // wrote $0
|
|
plant("team", 2500, "") // a stale $25 seat price, no Category/PriceAnnual/envelope
|
|
plant("free", 900, "") // wrote a charge over a rung that is genuinely free
|
|
|
|
if _, corrected, err := SeedPlans(c); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
} else if corrected < 4 {
|
|
t.Fatalf("corrected=%d, want >=4 (the planted bad rows)", corrected)
|
|
}
|
|
|
|
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
|
|
// The charge path resolves the CATALOG price — never the $0 under-charge,
|
|
// never the stale 2500 planted over team, and never the 900 planted over a
|
|
// free rung.
|
|
for _, slug := range []string{"free", "dev", "max-5x", "team"} {
|
|
want := lookupPlan(slug).Price
|
|
rp, err := resolveSubscriptionPlan(orgDB, slug)
|
|
if err != nil {
|
|
t.Fatalf("resolve %s: %v", slug, err)
|
|
}
|
|
if int64(rp.Price) != want {
|
|
t.Fatalf("RESOLVE %s price=%d, want %d (bad row NOT corrected → under-charge)", slug, rp.Price, want)
|
|
}
|
|
}
|
|
// Display projection is corrected too (category + minSeats restored).
|
|
rows, ok := planAuthorityRows(c)
|
|
if !ok {
|
|
t.Fatal("authority empty")
|
|
}
|
|
byslug := map[string]staticPlan{}
|
|
for _, r := range rows {
|
|
byslug[r.Slug] = r
|
|
}
|
|
if byslug["dev"].Category != "personal" {
|
|
t.Fatalf("dev category=%q, want personal (not corrected)", byslug["dev"].Category)
|
|
}
|
|
if ms := minSeatsOf(ptr(byslug["team"])); ms != 2 {
|
|
t.Fatalf("team minSeats=%d, want 2 (not corrected)", ms)
|
|
}
|
|
if want := lookupPlan("team").PriceAnnual; !sameCents(byslug["team"].PriceAnnual, want) {
|
|
t.Fatalf("team priceAnnual=%s, want %s (not corrected)", centsText(byslug["team"].PriceAnnual), centsText(want))
|
|
}
|
|
}
|
|
|
|
// TestProdPath_BundleSubscriptionNoAuthorityCorruption proves fix B: subscribing
|
|
// to a bundle PARENT must NOT write a partial $0 child row into the authority.
|
|
// Before the fix, the bundle expansion created the child at Price=0, so a DIRECT
|
|
// subscription to that child then resolved $0 and under-charged.
|
|
//
|
|
// The published ladder carries no bundles, so the parent/child pair is CREATED
|
|
// here rather than borrowed from the catalog. The bundle-expansion code is still
|
|
// live and still reachable by any plan an admin gives a `bundles` list, so the
|
|
// coverage belongs to the mechanism — not to whatever the catalog happens to sell.
|
|
func TestProdPath_BundleSubscriptionNoAuthorityCorruption(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
org := moneyOrg("acme")
|
|
|
|
adb := plan.AuthorityDB(c)
|
|
child := plan.New(adb)
|
|
child.Slug, child.Name, child.Category, child.Price, child.Managed = "bundle-child", "Bundle Child", "personal", 2900, true
|
|
if err := child.Create(); err != nil {
|
|
t.Fatalf("create child: %v", err)
|
|
}
|
|
parent := plan.New(adb)
|
|
parent.Slug, parent.Name, parent.Category, parent.Price, parent.Managed = "bundle-parent", "Bundle Parent", "personal", 4900, true
|
|
parent.Bundles = []string{"bundle-child"}
|
|
if err := parent.Create(); err != nil {
|
|
t.Fatalf("create parent: %v", err)
|
|
}
|
|
|
|
// A mint principal subscribes to the PARENT (a paid tier), which expands the
|
|
// bundle and touches the child row.
|
|
w := invokeSub(org, c, c1MintPrincipal, CreateBillingSubscription, `{"userId":"acme/owner","planId":"bundle-parent"}`)
|
|
if w.StatusCode != 201 {
|
|
t.Fatalf("create parent sub: status=%d body=%s", w.StatusCode, bodyOf(w))
|
|
}
|
|
|
|
// A direct child resolution must still be its real price, never a $0 partial
|
|
// the bundle expansion overwrote it with.
|
|
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
|
|
rp, err := resolveSubscriptionPlan(orgDB, "bundle-child")
|
|
if err != nil {
|
|
t.Fatalf("resolve bundle-child: %v", err)
|
|
}
|
|
if int64(rp.Price) != 2900 {
|
|
t.Fatalf("bundle-child resolves %d after a parent bundle sub, want 2900 (bundle corrupted the authority)", rp.Price)
|
|
}
|
|
}
|
|
|
|
func ptr(p staticPlan) *staticPlan { return &p }
|