// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "context" "database/sql" "log/slog" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "github.com/google/uuid" ) // TestIsRecurringPrice pins the one-time vs recurring distinction the Extras // bucket's purchase-affordance gate relies on (resolvePurchasableRecurring): // one-time-purchase delivery is the schema's reserved, unimplemented arm // (ux-ia-naming design.md D6) — nothing creates a purchase-linked // pool_provision from a one-time Stripe charge — so a one-time price must // never be treated as purchasable even when it otherwise passes // resolvePurchasable. A one-time price stores recurring_interval NULL // (00001_init.sql: a bare nullable VARCHAR, no "one_time" literal). func TestIsRecurringPrice(t *testing.T) { cases := []struct { name string price billing.Price want bool }{ {"recurring monthly price", billing.Price{RecurringInterval: sql.NullString{String: "month", Valid: true}}, true}, {"recurring yearly price", billing.Price{RecurringInterval: sql.NullString{String: "year", Valid: true}}, true}, {"one-time price (NULL interval)", billing.Price{RecurringInterval: sql.NullString{}}, false}, {"valid-but-empty interval treated as one-time", billing.Price{RecurringInterval: sql.NullString{String: "", Valid: true}}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := isRecurringPrice(c.price); got != c.want { t.Errorf("isRecurringPrice(%+v) = %v, want %v", c.price, got, c.want) } }) } } // TestBuildAddonsDataExtrasBucket covers member-product-discovery's Extras // bucket against a real DB after record-table-actions (maintainer, // 2026-09-19: a catalog offers what can be bought): published, public, // non-tier products with no price are not listed whatever their // display_category ("addon", some other label, blank), and a product that // is also a tier on a plan ladder is never listed here regardless of // carrying the "addon" label. A listed row needs a synced Stripe price // mapping that the handler reads through its own *sql.DB, which this // rollback fixture cannot supply, so the rendered row (its Add control and // its label badge) is pinned by member_addons_test.go instead. func TestBuildAddonsDataExtrasBucket(t *testing.T) { database := newRollbackTestDB(t) ctx := context.Background() tx, err := entitlements.BeginMaterializing(ctx, database, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() bq := billing.New(tx) sfx := uuid.New().String()[:8] h := &MemberProductsHandler{BillingQ: bq, Logger: slog.Default()} mkProduct := func(name, category string) billing.Product { t.Helper() p, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: name, DisplayCategory: sql.NullString{String: category, Valid: category != ""}, IsActive: true, IsPublic: true, LifecycleStatus: "published", }) if err != nil { t.Fatalf("create product %s: %v", name, err) } return p } labeled := mkProduct("Labeled Extra "+sfx, "addon") otherLabel := mkProduct("Other-Label Extra "+sfx, "storage") blank := mkProduct("Blank-Label Extra "+sfx, "") // A tier that also carries display_category = 'addon': membership is // structural (ladder-tier presence), so this must render in its plan // section only and never duplicate into Extras. tierWithAddonLabel := mkProduct("Tiered Extra "+sfx, "addon") ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{ Name: "Extras Test Ladder " + sfx, IsActive: true, }) if err != nil { t.Fatalf("create ladder: %v", err) } if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{ PlanLadderID: ladder.PlanLadderID, ProductID: tierWithAddonLabel.ProductID, }); err != nil { t.Fatalf("create tier: %v", err) } data := h.buildAddonsData(ctx) if data.Error != "" { t.Fatalf("unexpected error: %s", data.Error) } byID := make(map[string]AddonViewModel, len(data.Addons)) for _, a := range data.Addons { byID[a.ProductID] = a } // None of these carry a price, so none can be bought and none is // listed, whatever its label (member-product-discovery "A product that // cannot be bought is not listed"). for _, unpriced := range []billing.Product{labeled, otherLabel, blank} { if _, ok := byID[unpriced.ProductID]; ok { t.Errorf("%s has no price and must not be listed in Extras", unpriced.Name) } } if _, ok := byID[tierWithAddonLabel.ProductID]; ok { t.Error("tier product must not be duplicated into Extras regardless of its display_category label") } } // TestBuildAddonsDataOneTimePriceNotListed covers member-product-discovery // "A product that cannot be bought is not listed" for a real one-time // price: a published, public, non-tier product with an active price whose // recurring_interval is NULL cannot deliver (one-time delivery is the // schema's unimplemented arm), so the catalog leaves it out rather than // listing it without a purchase control (maintainer, 2026-09-19). func TestBuildAddonsDataOneTimePriceNotListed(t *testing.T) { database := newRollbackTestDB(t) ctx := context.Background() tx, err := entitlements.BeginMaterializing(ctx, database, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() bq := billing.New(tx) sfx := uuid.New().String()[:8] product, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "One-Time Extra " + sfx, IsActive: true, IsPublic: true, LifecycleStatus: "published", }) if err != nil { t.Fatalf("create product: %v", err) } if _, err := bq.CreatePrice(ctx, billing.CreatePriceParams{ ProductID: product.ProductID, Currency: "usd", UnitAmount: 500, // RecurringInterval intentionally left unset: a one-time price. }); err != nil { t.Fatalf("create price: %v", err) } h := &MemberProductsHandler{BillingQ: bq, Logger: slog.Default()} data := h.buildAddonsData(ctx) if data.Error != "" { t.Fatalf("unexpected error: %s", data.Error) } for _, a := range data.Addons { if a.ProductID == product.ProductID { t.Error("one-time-priced product must not be listed: its price cannot deliver, so it cannot be bought") } } }