Version approved pooled awake allowances and preserve historical terms
20 files changed,
+574
−65
cmd/console/billing.go+9 −5cmd/console/billingterms_20261003_test.go+64 −0implementation/carloku-paid-launch/stripe-provision.sh+2 −2internal/console/billingcalculation_test.go+1 −1internal/console/billingcurrency_test.go+4 −4internal/console/billinghandlers.go+6 −1internal/console/billingpage.go+11 −7internal/console/billingpage_test.go+2 −2internal/console/billingservice.go+11 −3internal/console/billingtaxshape_fixtures_test.go+1 −1internal/console/billingterms_20261003_test.go+84 −0internal/plans/allowances_20261003_test.go+199 −0internal/plans/plans-2026-10-03-v1.json+65 −0internal/plans/plans.go+38 −10internal/plans/plans_test.go+1 −1internal/plans/pricebook.go+25 −17internal/plans/pricebook.json+39 −0internal/plans/pricebook_test.go+3 −6internal/plans/provision_test.go+5 −1internal/plans/public-pricebook.json+4 −4
diff --git a/cmd/console/billing.go b/cmd/console/billing.go| index c5f97932f..f4ff5ce31 100644 |
| --- a/cmd/console/billing.go |
| +++ b/cmd/console/billing.go |
| @@ -131,8 +131,7 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| used := 0 |
| // catalog checks one book's keys (or the legacy keys, for book ""): |
| // all present, or, when it is not required, all absent. |
| - catalog := func(book string, required bool) bool { |
| - terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: book} |
| + catalog := func(book, version string, required bool) bool { |
| sold := []plans.Currency{""} |
| if book != plans.LegacyPriceBook { |
| var err error |
| @@ -144,7 +143,7 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| for _, plan := range paid { |
| for _, band := range bands { |
| for _, currency := range sold { |
| - key := terms.TermsVersion + "/" + plan + "/" + band |
| + key := version + "/" + plan + "/" + band |
| if book != plans.LegacyPriceBook { |
| key = book + "/" + plan + "/" + band + "/" + string(currency) |
| } |
| @@ -175,11 +174,16 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| return true |
| } |
| for _, book := range priceBooks() { |
| - if !catalog(book, book == cfg.PriceBook) { |
| + if !catalog(book, "", book == cfg.PriceBook) { |
| return nil, errBillingConfiguration |
| } |
| } |
| - if !catalog(plans.LegacyPriceBook, false) || used != len(cfg.Prices) { |
| + for _, version := range plans.TermsVersions() { |
| + if !catalog(plans.LegacyPriceBook, version, false) { |
| + return nil, errBillingConfiguration |
| + } |
| + } |
| + if used != len(cfg.Prices) { |
| return nil, errBillingConfiguration |
| } |
| return cfg, nil |
diff --git a/cmd/console/billingterms_20261003_test.go b/cmd/console/billingterms_20261003_test.go| new file mode 100644 |
| index 000000000..5b4363ac3 |
| --- /dev/null |
| +++ b/cmd/console/billingterms_20261003_test.go |
| @@ -0,0 +1,64 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func TestBillingConfigHistoricalLegacyCatalogs(t *testing.T) { |
| + const historical = "2026-09-07-v1" |
| + for _, book := range []string{"2026-10-01-draft1", plans.CurrentPriceBook} { |
| + for _, kind := range []string{"absent", "historical", "both", "partial historical", "partial current", "unknown terms", "shared price", "bad historical price"} { |
| + t.Run(book+"/"+kind, func(t *testing.T) { |
| + env := billingEnvFixture(t) |
| + env["CONSOLE_BILLING_PRICE_BOOK"] = book |
| + prices := map[string]string{} |
| + for key, id := range billingPricesFixture(false) { |
| + prices[strings.Replace(key, plans.CurrentPriceBook, book, 1)] = id |
| + } |
| + add := func(version string) { |
| + for _, plan := range []string{"personal", "community", "business"} { |
| + for _, band := range []string{"A", "B", "C", "D"} { |
| + prices[version+"/"+plan+"/"+band] = "price_" + version + plan + band |
| + } |
| + } |
| + } |
| + if kind != "absent" { |
| + add(historical) |
| + } |
| + valid := kind == "absent" || kind == "historical" || kind == "both" |
| + switch kind { |
| + case "both": |
| + add(plans.CurrentTermsVersion) |
| + case "partial historical": |
| + delete(prices, historical+"/personal/A") |
| + case "partial current": |
| + prices[plans.CurrentTermsVersion+"/personal/A"] = "price_partial" |
| + case "unknown terms": |
| + add("2030-01-01-v1") |
| + case "shared price": |
| + add(plans.CurrentTermsVersion) |
| + prices[plans.CurrentTermsVersion+"/personal/A"] = prices[historical+"/personal/A"] |
| + case "bad historical price": |
| + prices[historical+"/personal/A"] = "wrong_id" |
| + } |
| + raw, err := json.Marshal(prices) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + env["CONSOLE_STRIPE_PRICES"] = string(raw) |
| + cfg, err := billingConfig(func(key string) string { return env[key] }) |
| + if valid { |
| + if err != nil || cfg == nil || len(cfg.Prices) != len(prices) { |
| + t.Fatalf("retained catalogs refused: %v", err) |
| + } |
| + } else if err == nil || cfg != nil { |
| + t.Fatal("invalid retained catalog accepted") |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/implementation/carloku-paid-launch/stripe-provision.sh b/implementation/carloku-paid-launch/stripe-provision.sh| index f21fbd409..7836aa472 100755 |
| --- a/implementation/carloku-paid-launch/stripe-provision.sh |
| +++ b/implementation/carloku-paid-launch/stripe-provision.sh |
| @@ -39,12 +39,12 @@ |
| # |
| set -euo pipefail |
| -readonly TERMS="2026-09-07-v1" |
| +readonly TERMS="2026-10-03-v1" |
| # The price book (internal/plans/pricebook.json). Prices are per book, per |
| # currency, and the console keys CONSOLE_STRIPE_PRICES by it. A book whose |
| # amounts are not approved for sale is a draft; the console refuses a draft |
| # book in live mode, and so does this script. |
| -readonly BOOK="2026-10-01-draft1" |
| +readonly BOOK="2026-10-03-draft1" |
| readonly BOOK_STATUS="draft" |
| readonly CURRENCIES=(usd eur gbp) |
| readonly API_VERSION="2025-02-24.acacia" |
diff --git a/internal/console/billingcalculation_test.go b/internal/console/billingcalculation_test.go| index 5b614f3b9..32516e6e8 100644 |
| --- a/internal/console/billingcalculation_test.go |
| +++ b/internal/console/billingcalculation_test.go |
| @@ -18,7 +18,7 @@ import ( |
| func calculationFixture(plan string, quantity int64) (usage.Statement, []plans.Assignment) { |
| start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| st := usage.Statement{V: 1, Account: "acme", Month: "2026-09", Meters: map[string]float64{}, DaysInMonth: 30, Closed: true, UpdatedAt: start.AddDate(0, 1, 3)} |
| - return st, []plans.Assignment{{Revision: 1, EffectiveAt: start, TermsVersion: plans.CurrentTermsVersion, Plan: plan, Band: plans.BandA, Quantity: quantity, Status: "active", PaidThrough: start.AddDate(1, 0, 0)}} |
| + return st, []plans.Assignment{{Revision: 1, EffectiveAt: start, TermsVersion: "2026-09-07-v1", Plan: plan, Band: plans.BandA, Quantity: quantity, Status: "active", PaidThrough: start.AddDate(1, 0, 0)}} |
| } |
| func TestBillingCalculationBusinessMillionWrites(t *testing.T) { |
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go| index c35475960..a79810e5b 100644 |
| --- a/internal/console/billingcurrency_test.go |
| +++ b/internal/console/billingcurrency_test.go |
| @@ -1285,7 +1285,7 @@ func TestLegacyCalculationBytesAreUnchanged(t *testing.T) { |
| t.Fatal("fixture: the golden bytes and digest disagree") |
| } |
| statement := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000, usage.MeterStorageGBMonth: 40}, DaysInMonth: 30, Closed: true} |
| - calc, err := priceBillingCalculation(statement, monthHistory(plans.LegacyPriceTerms(plans.CurrentTermsVersion), false)) |
| + calc, err := priceBillingCalculation(statement, monthHistory(plans.LegacyPriceTerms("2026-09-07-v1"), false)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1307,7 +1307,7 @@ func TestLegacyCalculationBytesAreUnchanged(t *testing.T) { |
| // bytes a legacy month gets from this build. An assignment handed in |
| // with an explicit "eur", which the store never produces, marshals |
| // with that field and nothing else different: the money is the same. |
| - history := monthHistory(plans.LegacyPriceTerms(plans.CurrentTermsVersion), false) |
| + history := monthHistory(plans.LegacyPriceTerms("2026-09-07-v1"), false) |
| history[0].Currency = plans.EUR |
| spelled, err := priceBillingCalculation(statement, history) |
| if err != nil { |
| @@ -2629,7 +2629,7 @@ func TestBillingInclusionsFollowTheQuotedBand(t *testing.T) { |
| b := newBillingBrowser(t, fx) |
| m, _ := b.page("?country=IE") |
| for _, want := range []string{ |
| - "One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.", |
| + "One member. Each month: 100 awake hours, 2 GB storage, 2 GB transfer.", |
| "Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.", |
| "Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.", |
| } { |
| @@ -2640,7 +2640,7 @@ func TestBillingInclusionsFollowTheQuotedBand(t *testing.T) { |
| m, _ = b.page("?country=IN¤cy=gbp") |
| for _, want := range []string{ |
| "<strong>£7.70</strong> a year", |
| - "One member. Each month: 29.75 awake hours, 0.7 GB storage, 0.7 GB transfer.", |
| + "One member. Each month: 35 awake hours, 0.7 GB storage, 0.7 GB transfer.", |
| "Each member adds, per month: 5.95 awake hours, 0.175 GB storage, 0.14 GB transfer.", |
| "Each member adds, per month: 59.5 awake hours, 1.75 GB storage, 1.05 GB transfer, 10,500 replication writes.", |
| } { |
diff --git a/internal/console/billinghandlers.go b/internal/console/billinghandlers.go| index 1133845d4..62036d106 100644 |
| --- a/internal/console/billinghandlers.go |
| +++ b/internal/console/billinghandlers.go |
| @@ -60,7 +60,12 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| back := "/a/" + url.PathEscape(acct.Sqid) + "/billing" |
| - if r.PostForm.Get("consent") != plans.CurrentTermsVersion { |
| + book := plans.LegacyPriceBook |
| + if s.billing != nil { |
| + book = s.billing.cfg.PriceBook |
| + } |
| + version, err := plans.TermsVersionForBook(book) |
| + if err != nil || r.PostForm.Get("consent") != version { |
| http.Redirect(w, r, back+"?error=unavailable", http.StatusSeeOther) |
| return |
| } |
diff --git a/internal/console/billingpage.go b/internal/console/billingpage.go| index 8887eba0b..282233dca 100644 |
| --- a/internal/console/billingpage.go |
| +++ b/internal/console/billingpage.go |
| @@ -167,7 +167,10 @@ func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| TermsVersion: plans.CurrentTermsVersion, |
| MinQuantity: 10, |
| } |
| - if def, err := plans.Lookup(plans.CurrentTermsVersion, plans.PlanCommunity); err == nil { |
| + if s.billing != nil { |
| + v.TermsVersion, _ = plans.TermsVersionForBook(s.billing.cfg.PriceBook) |
| + } |
| + if def, err := plans.Lookup(v.TermsVersion, plans.PlanCommunity); err == nil { |
| v.MinQuantity = def.MinQuantity |
| } |
| switch e := r.URL.Query().Get("error"); e { |
| @@ -253,7 +256,7 @@ func (s *Server) handleBillingPage(w http.ResponseWriter, r *http.Request) { |
| } |
| if hit == nil { |
| v.QuoteError = true |
| - } else if pct, ok := billingBandPercent(hit.Band); ok { |
| + } else if pct, ok := billingBandPercent(v.TermsVersion, hit.Band); ok { |
| hit.Selected = true |
| band, country = hit.Band, hit.ISO2 |
| v.Quote = &billingQuote{ISO2: hit.ISO2, Country: hit.Name, Percent: pct} |
| @@ -322,7 +325,8 @@ func (s *Server) pageCurrency(r *http.Request, v *billingPageView, country strin |
| chosen = string(c) |
| } |
| } |
| - terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: book, Currency: plans.Currency(chosen)} |
| + version, _ := plans.TermsVersionForBook(book) |
| + terms := plans.PriceTerms{TermsVersion: version, PriceBook: book, Currency: plans.Currency(chosen)} |
| if terms.Legacy() { |
| terms.Currency = "" |
| v.CurrencyChosen, v.CurrencyHeld = false, false |
| @@ -377,12 +381,12 @@ func (s *Server) billingCountries() []billingCountry { |
| // billingBandPercent derives a band's percentage of the base price from the |
| // terms themselves (Personal at the band against Personal at band A), so the |
| // page can never state a factor the checkout would not charge. |
| -func billingBandPercent(band string) (int64, bool) { |
| - at, err := plans.Quote(plans.CurrentTermsVersion, plans.PlanPersonal, band, 1) |
| +func billingBandPercent(version, band string) (int64, bool) { |
| + at, err := plans.Quote(version, plans.PlanPersonal, band, 1) |
| if err != nil { |
| return 0, false |
| } |
| - base, err := plans.Quote(plans.CurrentTermsVersion, plans.PlanPersonal, plans.BandA, 1) |
| + base, err := plans.Quote(version, plans.PlanPersonal, plans.BandA, 1) |
| if err != nil || base.AnnualPriceMicroEUR <= 0 { |
| return 0, false |
| } |
| @@ -395,7 +399,7 @@ func billingBandPercent(band string) (int64, bool) { |
| func billingPlanRows(terms plans.PriceTerms, band string, members int64) []billingPlanRow { |
| var rows []billingPlanRow |
| for _, plan := range billingPaidPlans { |
| - def, err := plans.Lookup(plans.CurrentTermsVersion, plan) |
| + def, err := plans.Lookup(terms.TermsVersion, plan) |
| if err != nil { |
| continue |
| } |
diff --git a/internal/console/billingpage_test.go b/internal/console/billingpage_test.go| index cbc2b7cbd..a6a6de5eb 100644 |
| --- a/internal/console/billingpage_test.go |
| +++ b/internal/console/billingpage_test.go |
| @@ -313,11 +313,11 @@ func TestBillingPagePriceFormatting(t *testing.T) { |
| t.Fatalf("community at D, 3 members (billed as 10) = %+v", rows[1]) |
| } |
| for band, want := range map[string]int64{plans.BandA: 100, plans.BandB: 75, plans.BandC: 50, plans.BandD: 35} { |
| - if got, ok := billingBandPercent(band); !ok || got != want { |
| + if got, ok := billingBandPercent(plans.CurrentTermsVersion, band); !ok || got != want { |
| t.Errorf("billingBandPercent(%s) = %d %v, want %d", band, got, ok, want) |
| } |
| } |
| - if _, ok := billingBandPercent("Z"); ok { |
| + if _, ok := billingBandPercent(plans.CurrentTermsVersion, "Z"); ok { |
| t.Error("unknown band priced") |
| } |
| } |
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go| index 5247b5e63..2e0dec5a7 100644 |
| --- a/internal/console/billingservice.go |
| +++ b/internal/console/billingservice.go |
| @@ -104,11 +104,15 @@ func newBillingRuntime(cfg *BillingConfig) (*billingRuntime, error) { |
| if cfg.QuantityFence != "" && cfg.QuantityFence != billingQuantityFenceVerified { |
| return nil, errBillingUnavailable |
| } |
| + termsVersion, err := plans.TermsVersionForBook(cfg.PriceBook) |
| + if err != nil { |
| + return nil, err |
| + } |
| for country, band := range cfg.CountryBands { |
| if len(country) != 2 || strings.ToUpper(country) != country { |
| return nil, errBillingUnavailable |
| } |
| - if _, err := plans.Quote(plans.CurrentTermsVersion, plans.PlanPersonal, band, 1); err != nil { |
| + if _, err := plans.Quote(termsVersion, plans.PlanPersonal, band, 1); err != nil { |
| return nil, err |
| } |
| } |
| @@ -481,7 +485,11 @@ func (s *Server) startBillingCheckoutQuoted(ctx context.Context, acct *Account, |
| if err != nil { |
| return "", err |
| } |
| - def, err := plans.Lookup(plans.CurrentTermsVersion, plan) |
| + termsVersion, err := plans.TermsVersionForBook(b.cfg.PriceBook) |
| + if err != nil { |
| + return "", err |
| + } |
| + def, err := plans.Lookup(termsVersion, plan) |
| if err != nil || plan == plans.PlanFree { |
| return "", errBillingUnavailable |
| } |
| @@ -502,7 +510,7 @@ func (s *Server) startBillingCheckoutQuoted(ctx context.Context, acct *Account, |
| } |
| return "", err |
| } |
| - terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: b.cfg.PriceBook, Currency: chosen} |
| + terms := plans.PriceTerms{TermsVersion: termsVersion, PriceBook: b.cfg.PriceBook, Currency: chosen} |
| if terms.Legacy() { |
| terms.Currency = "" |
| } |
diff --git a/internal/console/billingtaxshape_fixtures_test.go b/internal/console/billingtaxshape_fixtures_test.go| index a5173fd23..0f3647f54 100644 |
| --- a/internal/console/billingtaxshape_fixtures_test.go |
| +++ b/internal/console/billingtaxshape_fixtures_test.go |
| @@ -49,7 +49,7 @@ func taxShapeInputs() map[string]struct { |
| st usage.Statement |
| h []plans.Assignment |
| } |
| - terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: plans.USD} |
| + terms := plans.PriceTerms{TermsVersion: "2026-09-07-v1", PriceBook: "2026-10-01-draft1", Currency: plans.USD} |
| sep15 := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) |
| out := map[string]cs{} |
| st, h := calculationFixture(plans.PlanPersonal, 1) |
diff --git a/internal/console/billingterms_20261003_test.go b/internal/console/billingterms_20261003_test.go| new file mode 100644 |
| index 000000000..45fc1174e |
| --- /dev/null |
| +++ b/internal/console/billingterms_20261003_test.go |
| @@ -0,0 +1,84 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func TestAllowanceTermsFollowConfiguredBookThroughCheckout(t *testing.T) { |
| + for _, book := range []string{"2026-10-01-draft1", plans.CurrentPriceBook} { |
| + t.Run(book, func(t *testing.T) { |
| + fx, f, acct := newCurrencyFixture(t) |
| + version, err := plans.TermsVersionForBook(book) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.billing.cfg.PriceBook = book |
| + for id, price := range f.prices { |
| + metadata := price["metadata"].(map[string]string) |
| + metadata["terms_version"], metadata["price_book"] = version, book |
| + terms := plans.PriceTerms{TermsVersion: version, PriceBook: book, Currency: plans.Currency(metadata["currency"])} |
| + fx.srv.billing.cfg.Prices[billingPriceKey(terms, metadata["plan"], metadata["band"])] = id |
| + } |
| + cfg := fx.srv.billing.cfg |
| + cfg.SecretKey = "rk_test_mock" |
| + if _, err := newBillingRuntime(&cfg); err != nil { |
| + t.Fatalf("startup refused known book: %v", err) |
| + } |
| + browser := newBillingBrowser(t, fx) |
| + page, fields := browser.page("?country=IE") |
| + if !strings.Contains(page, `name="consent" value="`+version+`"`) { |
| + t.Fatalf("page consent does not name selected book terms %s", version) |
| + } |
| + rows := billingPlanRows(plans.PriceTerms{TermsVersion: version, PriceBook: book, Currency: plans.EUR}, plans.BandA, 1) |
| + want := "85" |
| + if version == plans.CurrentTermsVersion { |
| + want = "100" |
| + } |
| + if len(rows) != 3 || rows[0].Hours != want { |
| + t.Fatalf("selected allowance %+v", rows) |
| + } |
| + fields.Set("plan", plans.PlanPersonal) |
| + wrong := plans.CurrentTermsVersion |
| + if wrong == version { |
| + wrong = "2026-09-07-v1" |
| + } |
| + fields.Set("consent", wrong) |
| + before := f.requests |
| + response := browser.post("/billing/checkout", fields) |
| + if response.Code != http.StatusSeeOther || !strings.HasSuffix(response.Header().Get("Location"), "?error=unavailable") || f.requests != before { |
| + t.Fatalf("wrong terms consent was not refused locally: %d %s", response.Code, response.Header().Get("Location")) |
| + } |
| + fields.Set("consent", version) |
| + response = browser.post("/billing/checkout", fields) |
| + if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "https://checkout.stripe.com/") { |
| + t.Fatalf("matching book consent failed: %d %s", response.Code, response.Header().Get("Location")) |
| + } |
| + intent, err := fx.st.billingCheckoutIntent(acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if intent.Terms != version || intent.PriceBook != book { |
| + t.Fatalf("intent lost configured terms %+v", intent) |
| + } |
| + form := f.forms[len(f.forms)-1] |
| + if form.Get("metadata[terms_version]") != version || form.Get("subscription_data[metadata][terms_version]") != version { |
| + t.Fatalf("provider request lost version %v", form) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestAllowanceTermsStartupRejectsUnknownBook(t *testing.T) { |
| + fx, _, _ := newCurrencyFixture(t) |
| + cfg := fx.srv.billing.cfg |
| + cfg.PriceBook = "unknown" |
| + cfg.SecretKey = "rk_test_mock" |
| + if _, err := newBillingRuntime(&cfg); !errors.Is(err, plans.ErrUnknownPriceBook) { |
| + t.Fatalf("unknown configured book: %v", err) |
| + } |
| +} |
diff --git a/internal/plans/allowances_20261003_test.go b/internal/plans/allowances_20261003_test.go| new file mode 100644 |
| index 000000000..c6f2ca604 |
| --- /dev/null |
| +++ b/internal/plans/allowances_20261003_test.go |
| @@ -0,0 +1,199 @@ |
| +package plans |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestApprovedAllowancesCurrentAndHistorical(t *testing.T) { |
| + for _, version := range []string{v1, CurrentTermsVersion} { |
| + for i, band := range bandOrder { |
| + free, err := Quote(version, PlanFree, band, 1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wantFree := int64(100 * 3600) |
| + wantPersonal := []int64{306000, 229500, 153000, 107100} |
| + if version == CurrentTermsVersion { |
| + wantFree = 216000 |
| + wantPersonal = []int64{360000, 270000, 180000, 126000} |
| + } |
| + if free.Allowances.AwakeSeconds != wantFree { |
| + t.Fatalf("%s %s free %+v", version, band, free) |
| + } |
| + personal, err := Quote(version, PlanPersonal, band, 1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if personal.Allowances.AwakeSeconds != wantPersonal[i] { |
| + t.Fatalf("%s %s personal %+v", version, band, personal) |
| + } |
| + for _, plan := range []string{PlanFree, PlanPersonal} { |
| + if _, err := Quote(version, plan, band, 2); !errors.Is(err, ErrInvalidQuantity) { |
| + t.Fatalf("%s quantity widened: %v", plan, err) |
| + } |
| + } |
| + } |
| + } |
| + for _, plan := range []string{PlanFree, PlanPersonal, PlanCommunity, PlanBusiness} { |
| + old, _ := Lookup(v1, plan) |
| + current, _ := Lookup(CurrentTermsVersion, plan) |
| + current.TermsVersion = old.TermsVersion |
| + current.ResourcesPerMember.AwakeSeconds = old.ResourcesPerMember.AwakeSeconds |
| + if !reflect.DeepEqual(old, current) { |
| + t.Fatalf("unapproved plan change: %s", plan) |
| + } |
| + if plan == PlanCommunity || plan == PlanBusiness { |
| + for _, band := range bandOrder { |
| + for _, qty := range []int64{10, 37, 1_000_000} { |
| + before, _ := Quote(v1, plan, band, qty) |
| + after, _ := Quote(CurrentTermsVersion, plan, band, qty) |
| + after.TermsVersion = before.TermsVersion |
| + if !reflect.DeepEqual(before, after) { |
| + t.Fatalf("pool changed %s %s %d", plan, band, qty) |
| + } |
| + } |
| + } |
| + } |
| + } |
| + // Usage is one monthly account total, including bursts exceeding two hours. |
| + free, _ := Quote(CurrentTermsVersion, PlanFree, BandD, 1) |
| + usage := Resources{AwakeSeconds: 24*3600 + 24*3600 + 12*3600} |
| + over, err := CalculateOverage(CurrentTermsVersion, PlanFree, usage, free.Allowances) |
| + if err != nil || over.AwakeMicroEUR != 0 { |
| + t.Fatalf("monthly pool split into daily limits: %+v %v", over, err) |
| + } |
| + raw, _ := json.Marshal(free.Allowances) |
| + var axes map[string]int64 |
| + if err := json.Unmarshal(raw, &axes); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(axes) != 4 || axes["awake_seconds"] != 216000 { |
| + t.Fatalf("allowance shape %s", raw) |
| + } |
| +} |
| + |
| +func TestApprovedAllowanceBooksKeepHistoricalExport(t *testing.T) { |
| + const oldBook = "2026-10-01-draft1" |
| + before, err := PublicPriceBook(oldBook) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hash := sha256.Sum256(before) |
| + if hex.EncodeToString(hash[:]) != "d91d5a48669c1df853dda48e6ffec97e39b72279acc851d538468cdef1d478d2" { |
| + t.Fatalf("historical public book changed: %x", hash) |
| + } |
| + old := priceBooks[oldBook] |
| + next := priceBooks[CurrentPriceBook] |
| + oldCopy, nextCopy := *old, *next |
| + nextCopy.Version = oldCopy.Version |
| + nextCopy.TermsVersion = oldCopy.TermsVersion |
| + if !reflect.DeepEqual(oldCopy, nextCopy) { |
| + t.Fatal("book changed beyond identifiers") |
| + } |
| + raw, err := json.Marshal(booksDoc{Books: []bookDoc{*old, *next}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + decoded, err := decodePriceBooks(raw, termsByVersion, CountryBands()) |
| + if err != nil || len(decoded) != 2 { |
| + t.Fatalf("roundtrip %v", err) |
| + } |
| + for _, book := range []string{oldBook, CurrentPriceBook} { |
| + version, err := TermsVersionForBook(book) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + expected := v1 |
| + if book == CurrentPriceBook { |
| + expected = CurrentTermsVersion |
| + } |
| + if version != expected { |
| + t.Fatalf("book routing %s -> %s", book, version) |
| + } |
| + for _, currency := range allCurrencies { |
| + term := PriceTerms{TermsVersion: version, PriceBook: book, Currency: currency} |
| + q, err := PriceFor(term, PlanPersonal, BandA, 1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := int64(306000) |
| + if version == CurrentTermsVersion { |
| + want = 360000 |
| + } |
| + if q.Allowances.AwakeSeconds != want { |
| + t.Fatalf("book allowance %+v", q) |
| + } |
| + wrong := v1 |
| + if version == v1 { |
| + wrong = CurrentTermsVersion |
| + } |
| + term.TermsVersion = wrong |
| + if !errors.Is(term.Validate(), ErrUnknownTermsVersion) { |
| + t.Fatal("mismatched book and terms accepted") |
| + } |
| + } |
| + } |
| + if version, err := TermsVersionForBook(""); err != nil || version != CurrentTermsVersion { |
| + t.Fatalf("new legacy quote default %s %v", version, err) |
| + } |
| + if _, err := TermsVersionForBook("unknown"); !errors.Is(err, ErrUnknownPriceBook) { |
| + t.Fatal(err) |
| + } |
| + if ok, err := BookIsApproved(CurrentPriceBook); err != nil || ok { |
| + t.Fatal("unapproved currency amounts activated") |
| + } |
| +} |
| + |
| +func TestApprovedAllowancesHistoricalMonth(t *testing.T) { |
| + for _, tc := range []struct { |
| + terms, book string |
| + awake int64 |
| + }{{v1, "", 306000}, {v1, "2026-10-01-draft1", 306000}, {CurrentTermsVersion, CurrentPriceBook, 360000}} { |
| + a := Assignment{EffectiveAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Revision: 1, TermsVersion: tc.terms, Plan: PlanPersonal, Band: BandA, Quantity: 1, Status: "active", PaidThrough: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC), PriceBook: tc.book} |
| + if tc.book != "" { |
| + a.Currency = EUR |
| + } |
| + month, err := AllowanceForMonth("2026-09", []Assignment{a}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if month.Resources.AwakeSeconds != tc.awake || month.TermsVersion != tc.terms { |
| + t.Fatalf("historical month reinterpreted: %+v", month) |
| + } |
| + over, err := OverageFor(month.PriceTerms(), PlanPersonal, Resources{AwakeSeconds: 100 * 3600}, month.Resources) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := int64(150000) |
| + if tc.terms == CurrentTermsVersion { |
| + want = 0 |
| + } |
| + if over.AwakeMicro != want { |
| + t.Fatalf("historical overage %+v", over) |
| + } |
| + } |
| +} |
| + |
| +func TestApprovedAllowanceTermsGolden(t *testing.T) { |
| + hash := sha256.Sum256(currentPlansJSON) |
| + if hex.EncodeToString(hash[:]) != "e746d6b3c88d988d01e2043b1adb80fe2edfc51e7133631025d16893805fc7d4" { |
| + t.Fatalf("versioned approved allowance terms changed: %x", hash) |
| + } |
| +} |
| + |
| +func TestApprovedAllowanceTermsVersionsRetained(t *testing.T) { |
| + versions := TermsVersions() |
| + if !reflect.DeepEqual(versions, []string{v1, CurrentTermsVersion}) { |
| + t.Fatalf("supported terms %v", versions) |
| + } |
| + versions[0] = "changed" |
| + if got := TermsVersions(); got[0] != v1 { |
| + t.Fatal("caller changed embedded terms list") |
| + } |
| +} |
diff --git a/internal/plans/plans-2026-10-03-v1.json b/internal/plans/plans-2026-10-03-v1.json| new file mode 100644 |
| index 000000000..a22e4d81f |
| --- /dev/null |
| +++ b/internal/plans/plans-2026-10-03-v1.json |
| @@ -0,0 +1,65 @@ |
| +{ |
| + "version": "2026-10-03-v1", |
| + "band_factors_bps": { |
| + "A": 10000, |
| + "B": 7500, |
| + "C": 5000, |
| + "D": 3500 |
| + }, |
| + "overage_rates_micro_eur": { |
| + "awake_hour": 10000, |
| + "storage_gb_month": 106000, |
| + "transfer_gb": 414000, |
| + "writes_1000": 23000 |
| + }, |
| + "plans": { |
| + "free": { |
| + "annual_base_micro_eur": 0, |
| + "min_quantity": 1, |
| + "max_quantity": 1, |
| + "band_scaled": false, |
| + "resources_per_member": { |
| + "awake_seconds": 216000, |
| + "storage_byte_months": 250000000, |
| + "transfer_bytes": 1000000000, |
| + "writes": 0 |
| + } |
| + }, |
| + "personal": { |
| + "annual_base_micro_eur": 24000000, |
| + "min_quantity": 1, |
| + "max_quantity": 1, |
| + "band_scaled": true, |
| + "resources_per_member": { |
| + "awake_seconds": 360000, |
| + "storage_byte_months": 2000000000, |
| + "transfer_bytes": 2000000000, |
| + "writes": 0 |
| + } |
| + }, |
| + "community": { |
| + "annual_base_micro_eur": 5000000, |
| + "min_quantity": 10, |
| + "max_quantity": 1000000, |
| + "band_scaled": true, |
| + "resources_per_member": { |
| + "awake_seconds": 61200, |
| + "storage_byte_months": 500000000, |
| + "transfer_bytes": 400000000, |
| + "writes": 0 |
| + } |
| + }, |
| + "business": { |
| + "annual_base_micro_eur": 50000000, |
| + "min_quantity": 10, |
| + "max_quantity": 1000000, |
| + "band_scaled": true, |
| + "resources_per_member": { |
| + "awake_seconds": 612000, |
| + "storage_byte_months": 5000000000, |
| + "transfer_bytes": 3000000000, |
| + "writes": 30000 |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/plans/plans.go b/internal/plans/plans.go| index 6d2bd8a14..806cc90d9 100644 |
| --- a/internal/plans/plans.go |
| +++ b/internal/plans/plans.go |
| @@ -19,11 +19,15 @@ import ( |
| "io" |
| "math" |
| "math/big" |
| + "sort" |
| ) |
| //go:embed plans.json |
| var plansJSON []byte |
| +//go:embed plans-2026-10-03-v1.json |
| +var currentPlansJSON []byte |
| + |
| // Stable, terse technical error codes. Never user-facing prose. |
| var ( |
| ErrUnknownTermsVersion = errors.New("plans: unknown_terms_version") |
| @@ -51,7 +55,7 @@ const ( |
| BandD = "D" |
| // CurrentTermsVersion is the terms version embedded by this build. |
| - CurrentTermsVersion = "2026-09-07-v1" |
| + CurrentTermsVersion = "2026-10-03-v1" |
| ) |
| const ( |
| @@ -130,14 +134,35 @@ type termsDoc struct { |
| Plans map[string]planTerms `json:"plans"` |
| } |
| -var terms termsDoc |
| +var termsByVersion = loadTerms() |
| -func init() { |
| - t, err := decodeTerms(plansJSON) |
| - if err != nil { |
| - panic("plans: invalid embedded plans.json: " + err.Error()) |
| +func loadTerms() map[string]*termsDoc { |
| + out := make(map[string]*termsDoc) |
| + for _, raw := range [][]byte{plansJSON, currentPlansJSON} { |
| + t, err := decodeTerms(raw) |
| + if err != nil { |
| + panic("plans: invalid embedded plans.json: " + err.Error()) |
| + } |
| + if out[t.Version] != nil { |
| + panic(ErrUnknownTermsVersion) |
| + } |
| + out[t.Version] = t |
| + } |
| + if out[CurrentTermsVersion] == nil { |
| + panic(ErrUnknownTermsVersion) |
| } |
| - terms = *t |
| + return out |
| +} |
| + |
| +// TermsVersions lists all embedded terms versions in sorted order. Historical |
| +// purchases keep their version, so older versions remain available. |
| +func TermsVersions() []string { |
| + out := make([]string, 0, len(termsByVersion)) |
| + for version := range termsByVersion { |
| + out = append(out, version) |
| + } |
| + sort.Strings(out) |
| + return out |
| } |
| // decodeTerms strictly decodes and validates a terms document: unknown |
| @@ -247,7 +272,8 @@ type PlanDef struct { |
| // scaling or quantity pooling. It errors on an unknown terms version or |
| // plan key rather than guessing. |
| func Lookup(version, plan string) (*PlanDef, error) { |
| - if version != terms.Version { |
| + terms, ok := termsByVersion[version] |
| + if !ok { |
| return nil, ErrUnknownTermsVersion |
| } |
| pt, ok := terms.Plans[plan] |
| @@ -286,7 +312,8 @@ type QuoteResult struct { |
| // per-member base resource times quantity times the band factor, never a |
| // per-member figure. |
| func Quote(version, plan, band string, qty int64) (*QuoteResult, error) { |
| - if version != terms.Version { |
| + terms, ok := termsByVersion[version] |
| + if !ok { |
| return nil, ErrUnknownTermsVersion |
| } |
| pt, ok := terms.Plans[plan] |
| @@ -362,7 +389,8 @@ type OverageResult struct { |
| // object-store egress, never counted twice). Excess below zero on any axis |
| // prices as zero on that axis, never a credit. |
| func CalculateOverage(version, plan string, usage, allowance Resources) (*OverageResult, error) { |
| - if version != terms.Version { |
| + terms, ok := termsByVersion[version] |
| + if !ok { |
| return nil, ErrUnknownTermsVersion |
| } |
| if _, ok := terms.Plans[plan]; !ok { |
diff --git a/internal/plans/plans_test.go b/internal/plans/plans_test.go| index ac8c67a67..9892c9c34 100644 |
| --- a/internal/plans/plans_test.go |
| +++ b/internal/plans/plans_test.go |
| @@ -8,7 +8,7 @@ import ( |
| "testing" |
| ) |
| -const v1 = CurrentTermsVersion |
| +const v1 = "2026-09-07-v1" |
| // --- Published examples (2026-09-07-carloku-pricing-plans-design.md) --- |
diff --git a/internal/plans/pricebook.go b/internal/plans/pricebook.go| index 6ae5212ae..ab494d768 100644 |
| --- a/internal/plans/pricebook.go |
| +++ b/internal/plans/pricebook.go |
| @@ -60,7 +60,7 @@ const ( |
| // existed: none. It means EUR, from the terms' own figures. |
| LegacyPriceBook = "" |
| // CurrentPriceBook is the book new purchases are priced from. |
| - CurrentPriceBook = "2026-10-01-draft1" |
| + CurrentPriceBook = "2026-10-03-draft1" |
| BookDraft = "draft" |
| BookApproved = "approved" |
| @@ -107,17 +107,6 @@ type booksDoc struct { |
| var priceBooks map[string]*bookDoc |
| func init() { |
| - // The terms are needed to validate a book, and package-level init |
| - // order across files is not something to lean on: decode them here if |
| - // plans.go's init has not run yet. |
| - t := terms |
| - if t.Version == "" { |
| - decoded, err := decodeTerms(plansJSON) |
| - if err != nil { |
| - panic("plans: invalid embedded plans.json: " + err.Error()) |
| - } |
| - t = *decoded |
| - } |
| // Likewise the countries a book may name. |
| known := countryBandsByISO2 |
| if known == nil { |
| @@ -127,7 +116,7 @@ func init() { |
| } |
| known = decoded |
| } |
| - books, err := decodePriceBooks(priceBookJSON, &t, known) |
| + books, err := decodePriceBooks(priceBookJSON, termsByVersion, known) |
| if err != nil { |
| panic("plans: invalid embedded pricebook.json: " + err.Error()) |
| } |
| @@ -146,7 +135,7 @@ func init() { |
| // may give a currency only to a country that has a band: a code that is |
| // not one (a typo, "UK" for "GB") would otherwise load, and that |
| // country's buyers would silently get the default. |
| -func decodePriceBooks(data []byte, t *termsDoc, knownCountries map[string]string) (map[string]*bookDoc, error) { |
| +func decodePriceBooks(data []byte, versions map[string]*termsDoc, knownCountries map[string]string) (map[string]*bookDoc, error) { |
| // encoding/json keeps the last of two equal keys and says nothing. In |
| // a price list that is one price silently replacing another. |
| if err := rejectDuplicateKeys(data); err != nil { |
| @@ -173,6 +162,10 @@ func decodePriceBooks(data []byte, t *termsDoc, knownCountries map[string]string |
| if out[b.Version] != nil { |
| return nil, fmt.Errorf("book %q appears twice", b.Version) |
| } |
| + t, ok := versions[b.TermsVersion] |
| + if !ok { |
| + return nil, ErrUnknownTermsVersion |
| + } |
| if err := validateBook(b, t, knownCountries); err != nil { |
| return nil, fmt.Errorf("book %q: %w", b.Version, err) |
| } |
| @@ -447,6 +440,20 @@ func BookIsApproved(book string) (bool, error) { |
| return b.Status == BookApproved, nil |
| } |
| +// TermsVersionForBook selects the terms for a new quote from book. An empty |
| +// book selects current terms for a new legacy-EUR quote. Stored purchases |
| +// retain their own TermsVersion and must not use this default. |
| +func TermsVersionForBook(book string) (string, error) { |
| + if book == LegacyPriceBook { |
| + return CurrentTermsVersion, nil |
| + } |
| + b, err := lookupBook(book) |
| + if err != nil { |
| + return "", err |
| + } |
| + return b.TermsVersion, nil |
| +} |
| + |
| // PriceBooks lists every price book this build has, sorted. A purchase |
| // recorded under a book stays priced from it for as long as it lasts, so a |
| // book that has ever been sold from must stay in the build. |
| @@ -504,7 +511,7 @@ func (t PriceTerms) Money() Currency { |
| // Validate checks the identity names terms and a book this build has and a |
| // currency that book sells in. |
| func (t PriceTerms) Validate() error { |
| - if t.TermsVersion != terms.Version { |
| + if termsByVersion[t.TermsVersion] == nil { |
| return ErrUnknownTermsVersion |
| } |
| if t.Legacy() { |
| @@ -588,7 +595,7 @@ type Charges struct { |
| func (t PriceTerms) overageRates() (OverageRates, int64) { |
| if t.Legacy() { |
| - return terms.OverageRatesMicroEUR, invoiceFloorMicroEUR |
| + return termsByVersion[t.TermsVersion].OverageRatesMicroEUR, invoiceFloorMicroEUR |
| } |
| b := priceBooks[t.PriceBook] |
| return b.OverageRatesMicro[t.Currency], b.OverageFloorMicro[t.Currency] |
| @@ -602,7 +609,7 @@ func OverageFor(t PriceTerms, plan string, usage, allowance Resources) (*Charges |
| if err := t.Validate(); err != nil { |
| return nil, err |
| } |
| - if _, ok := terms.Plans[plan]; !ok { |
| + if _, ok := termsByVersion[t.TermsVersion].Plans[plan]; !ok { |
| return nil, ErrUnknownPlan |
| } |
| if err := requireNonNegative(usage); err != nil { |
| @@ -729,6 +736,7 @@ func PublicPriceBook(book string) ([]byte, error) { |
| if err != nil { |
| return nil, err |
| } |
| + terms := termsByVersion[b.TermsVersion] |
| out := publicBook{ |
| Schema: 1, PriceBook: b.Version, TermsVersion: b.TermsVersion, Status: b.Status, DefaultCurrency: b.DefaultCurrency, |
| CountryCurrency: map[string]Currency{}, CountryBand: CountryBands(), BandFactorsBps: map[string]int64{}, Overage: map[Currency]publicOverage{}, |
diff --git a/internal/plans/pricebook.json b/internal/plans/pricebook.json| index fcaf227f5..a8ad3ced1 100644 |
| --- a/internal/plans/pricebook.json |
| +++ b/internal/plans/pricebook.json |
| @@ -38,6 +38,45 @@ |
| "gbp": {"awake_hour": 9000, "storage_gb_month": 95400, "transfer_gb": 372600, "writes_1000": 20700} |
| }, |
| "overage_floor_micro": {"usd": 1200000, "eur": 1000000, "gbp": 900000} |
| + }, |
| + { |
| + "version": "2026-10-03-draft1", |
| + "terms_version": "2026-10-03-v1", |
| + "status": "draft", |
| + "default_currency": "usd", |
| + "currencies": ["usd", "eur", "gbp"], |
| + "country_currency": { |
| + "eur": ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"], |
| + "gbp": ["GB"] |
| + }, |
| + "annual_base_minor": { |
| + "personal": {"usd": 2800, "eur": 2400, "gbp": 2200}, |
| + "community": {"usd": 600, "eur": 500, "gbp": 450}, |
| + "business": {"usd": 6000, "eur": 5000, "gbp": 4500} |
| + }, |
| + "annual_unit_minor": { |
| + "personal": { |
| + "usd": {"A": 2800, "B": 2100, "C": 1400, "D": 980}, |
| + "eur": {"A": 2400, "B": 1800, "C": 1200, "D": 840}, |
| + "gbp": {"A": 2200, "B": 1650, "C": 1100, "D": 770} |
| + }, |
| + "community": { |
| + "usd": {"A": 600, "B": 450, "C": 300, "D": 210}, |
| + "eur": {"A": 500, "B": 375, "C": 250, "D": 175}, |
| + "gbp": {"A": 450, "B": 338, "C": 225, "D": 158} |
| + }, |
| + "business": { |
| + "usd": {"A": 6000, "B": 4500, "C": 3000, "D": 2100}, |
| + "eur": {"A": 5000, "B": 3750, "C": 2500, "D": 1750}, |
| + "gbp": {"A": 4500, "B": 3375, "C": 2250, "D": 1575} |
| + } |
| + }, |
| + "overage_rates_micro": { |
| + "usd": {"awake_hour": 12000, "storage_gb_month": 127200, "transfer_gb": 496800, "writes_1000": 27600}, |
| + "eur": {"awake_hour": 10000, "storage_gb_month": 106000, "transfer_gb": 414000, "writes_1000": 23000}, |
| + "gbp": {"awake_hour": 9000, "storage_gb_month": 95400, "transfer_gb": 372600, "writes_1000": 20700} |
| + }, |
| + "overage_floor_micro": {"usd": 1200000, "eur": 1000000, "gbp": 900000} |
| } |
| ] |
| } |
diff --git a/internal/plans/pricebook_test.go b/internal/plans/pricebook_test.go| index 0aace1e62..86939cf7d 100644 |
| --- a/internal/plans/pricebook_test.go |
| +++ b/internal/plans/pricebook_test.go |
| @@ -430,7 +430,7 @@ func TestAllowanceForMonthKeepsOneCurrency(t *testing.T) { |
| // a frozen month's calculation holds its assignments, and its digest is of |
| // those bytes. |
| func TestLegacyAssignmentMarshalsAsBefore(t *testing.T) { |
| - a := Assignment{EffectiveAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Revision: 3, TermsVersion: CurrentTermsVersion, Plan: PlanPersonal, Band: BandB, Quantity: 1, Status: "active", PaidThrough: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)} |
| + a := Assignment{EffectiveAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Revision: 3, TermsVersion: v1, Plan: PlanPersonal, Band: BandB, Quantity: 1, Status: "active", PaidThrough: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)} |
| raw, err := json.Marshal(a) |
| if err != nil { |
| t.Fatal(err) |
| @@ -439,7 +439,7 @@ func TestLegacyAssignmentMarshalsAsBefore(t *testing.T) { |
| if string(raw) != before { |
| t.Fatalf("a legacy assignment marshals as\n%s\nand used to marshal as\n%s", raw, before) |
| } |
| - m := MonthAllowance{Month: "2026-09", TermsVersion: CurrentTermsVersion, Assignments: []Assignment{a}} |
| + m := MonthAllowance{Month: "2026-09", TermsVersion: v1, Assignments: []Assignment{a}} |
| raw, _ = json.Marshal(m) |
| if strings.Contains(string(raw), "currency") || strings.Contains(string(raw), "price_book") { |
| t.Fatalf("a legacy month allowance gained a field: %s", raw) |
| @@ -449,10 +449,7 @@ func TestLegacyAssignmentMarshalsAsBefore(t *testing.T) { |
| // What a book must be before it is trusted. Each case is the embedded |
| // book with one thing wrong. |
| func TestPriceBookValidation(t *testing.T) { |
| - tm, err := decodeTerms(plansJSON) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| + tm := termsByVersion |
| countries := CountryBands() |
| if _, err := decodePriceBooks(priceBookJSON, tm, countries); err != nil { |
| t.Fatalf("the embedded book: %v", err) |
diff --git a/internal/plans/provision_test.go b/internal/plans/provision_test.go| index 8eca60097..6532e724e 100644 |
| --- a/internal/plans/provision_test.go |
| +++ b/internal/plans/provision_test.go |
| @@ -44,6 +44,10 @@ func TestStripeProvisionScriptMatchesThePriceBook(t *testing.T) { |
| if book == nil || book[1] != CurrentPriceBook { |
| t.Fatalf("the script provisions book %v; the console sells from %s", book, CurrentPriceBook) |
| } |
| + version := regexp.MustCompile(`(?m)^readonly TERMS="([^"]+)"`).FindStringSubmatch(script) |
| + if version == nil || version[1] != CurrentTermsVersion { |
| + t.Fatalf("script terms %v; current %s", version, CurrentTermsVersion) |
| + } |
| status := regexp.MustCompile(`(?m)^readonly BOOK_STATUS="([^"]+)"`).FindStringSubmatch(script) |
| approved, err := BookIsApproved(CurrentPriceBook) |
| if err != nil { |
| @@ -54,7 +58,7 @@ func TestStripeProvisionScriptMatchesThePriceBook(t *testing.T) { |
| } |
| want := map[string]int64{} |
| for _, plan := range paidPlans { |
| - qty := terms.Plans[plan].MinQuantity |
| + qty := termsByVersion[CurrentTermsVersion].Plans[plan].MinQuantity |
| for _, band := range bandOrder { |
| for _, c := range priceBooks[CurrentPriceBook].Currencies { |
| p, err := PriceFor(PriceTerms{TermsVersion: CurrentTermsVersion, PriceBook: CurrentPriceBook, Currency: c}, plan, band, qty) |
diff --git a/internal/plans/public-pricebook.json b/internal/plans/public-pricebook.json| index 9e53c66d0..8d325d7f3 100644 |
| --- a/internal/plans/public-pricebook.json |
| +++ b/internal/plans/public-pricebook.json |
| @@ -1,7 +1,7 @@ |
| { |
| "schema": 1, |
| - "price_book": "2026-10-01-draft1", |
| - "terms_version": "2026-09-07-v1", |
| + "price_book": "2026-10-03-draft1", |
| + "terms_version": "2026-10-03-v1", |
| "status": "draft", |
| "default_currency": "usd", |
| "currencies": [ |
| @@ -285,7 +285,7 @@ |
| } |
| }, |
| "resources_per_member": { |
| - "awake_seconds": 360000, |
| + "awake_seconds": 216000, |
| "storage_byte_months": 250000000, |
| "transfer_bytes": 1000000000, |
| "writes": 0 |
| @@ -317,7 +317,7 @@ |
| } |
| }, |
| "resources_per_member": { |
| - "awake_seconds": 306000, |
| + "awake_seconds": 360000, |
| "storage_byte_months": 2000000000, |
| "transfer_bytes": 2000000000, |
| "writes": 0 |