server: pricing bands in the cloud rails — book, checkout, ledger, tax plumbing (Phase 0) (#1075)
The server half of docs/pricing-bands.md Phase 0. Dark until the operator's price book adds bands: - Price book: optional per-tier "bands" (supporter/reduced/solidarity, each its own Stripe Price — the tier's own price IS base), "eur" numeric sticker (the picker's ≈ display input), "business" flag. Validation refuses unknown band names, incomplete bands, and any price id appearing twice (ambiguity would misledger webhooks). - Checkout: optional band param — whatever band arrives is honoured at the book's exact price and stamped in session + subscription metadata; a band the tier doesn't offer is a 400, never a silent re-price. - Webhook: tierByPrice → tierBandByPrice; the ledger's new band column records what's actually paid (the subscription's PRICE stays the truth; a price-less event keeps the recorded band). - Stripe Tax: LCHAT_STRIPE_AUTOMATIC_TAX=1 opts Checkout Sessions in (automatic_tax; Business tiers also collect a VAT ID for the EU reverse charge). An operator knob, flipped with the band-price rollout — every live Price must resolve a tax_behavior first. - Billing Portal: LCHAT_STRIPE_PORTAL_CONFIGS picks a per-band configuration (a portal only offers switches inside the customer's band; band changes are the home surface's job), falling back to the single config. - Free pool: a paid row's revenue input is its BAND's net_eur_year — a Reduced subscription feeds the budget its real net, not base's.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+552
−60
billing.go+161 −30cloud.go+10 −0cloud_bands_test.go+316 −0cloud_billing_test.go+4 −4cloud_db.go+27 −17cloudreplication_test.go+1 −1docs/cloud-provisioner.md+17 −1meter_test.go+1 −1pool.go+15 −6
diff --git a/billing.go b/billing.go| index 278774f..0e2ed43 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -33,6 +33,7 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| + "slices" |
| "strconv" |
| "strings" |
| "time" |
| @@ -54,8 +55,37 @@ type billingTier struct { |
| // that the server never asks Stripe what a price costs. Optional and |
| // additive: entries without it contribute nothing to the pool budget. |
| NetEURYear float64 `json:"net_eur_year"` |
| + // EUR is the sticker amount as a number (the label repeated, machine- |
| + // readably) — the picker's local-currency ≈ display converts it client-side |
| + // (docs/pricing-bands.md § Local-currency display). Optional: entries |
| + // without it simply show no ≈ figure. |
| + EUR float64 `json:"eur,omitempty"` |
| + // Business marks a business-directed tier: its prices are quoted ex-VAT |
| + // (created with tax_behavior=exclusive) and its checkout collects a VAT ID |
| + // so EU businesses get the reverse charge (docs/pricing-bands.md § VAT). |
| + Business bool `json:"business,omitempty"` |
| + // Bands are the honor prices for this tier (docs/pricing-bands.md): the |
| + // same product at supporter/reduced/solidarity multipliers, each its own |
| + // Stripe Price. The tier's own label/price IS the base band. Optional and |
| + // backward-compatible: a tier without bands behaves exactly as before. |
| + Bands map[string]billingBand `json:"bands,omitempty"` |
| } |
| +// billingBand is one non-base honor price of a tier. |
| +type billingBand struct { |
| + Label string `json:"label"` |
| + Price string `json:"price"` |
| + NetEURYear float64 `json:"net_eur_year"` |
| + EUR float64 `json:"eur,omitempty"` |
| +} |
| + |
| +// bandBase names the band every tier has implicitly (its top-level price); |
| +// bandKeys are the only non-base bands the book may define — a fixed, small |
| +// vocabulary keeps ledger rows and telemetry legible. |
| +const bandBase = "base" |
| + |
| +var bandKeys = []string{"supporter", "reduced", "solidarity"} |
| + |
| // stripeBilling holds the cloud service's Stripe configuration. nil on a |
| // deployment that doesn't offer upgrades — billing stays strictly additive. |
| type stripeBilling struct { |
| @@ -64,7 +94,18 @@ type stripeBilling struct { |
| webhookSecret string // signing secret for the webhook endpoint |
| tiers []billingTier // the price book, in display order |
| portalConfig string // optional Billing Portal configuration id |
| - client *http.Client |
| + // portalConfigs optionally picks a Billing Portal configuration per band |
| + // (LCHAT_STRIPE_PORTAL_CONFIGS, JSON {band: id}) — band changes go through |
| + // the home surface, never the portal, so each band's portal only offers |
| + // switches inside that band. Falls back to portalConfig. |
| + portalConfigs map[string]string |
| + // automaticTax opts Checkout Sessions into Stripe Tax |
| + // (LCHAT_STRIPE_AUTOMATIC_TAX=1). An operator knob rather than always-on: |
| + // it requires every live Price to resolve a tax_behavior, so it flips on |
| + // with the band-price rollout, not with a code deploy |
| + // (docs/pricing-bands.md § VAT). |
| + automaticTax bool |
| + client *http.Client |
| } |
| // tier looks a purchasable tier up by key. |
| @@ -77,24 +118,63 @@ func (b *stripeBilling) tier(key string) (billingTier, bool) { |
| return billingTier{}, false |
| } |
| -// tierByPrice maps a Stripe price id back to its tier — how a Billing Portal |
| -// plan switch reaches the ledger (the subscription's PRICE is the truth; its |
| -// metadata still carries the tier bought at the original checkout). |
| -func (b *stripeBilling) tierByPrice(priceID string) (billingTier, bool) { |
| +// tierBandByPrice maps a Stripe price id back to its (tier, band) — how a |
| +// Billing Portal plan switch reaches the ledger (the subscription's PRICE is |
| +// the truth; its metadata still carries what the original checkout bought). |
| +// Still a flat scan over a few dozen rows. |
| +func (b *stripeBilling) tierBandByPrice(priceID string) (billingTier, string, bool) { |
| for _, t := range b.tiers { |
| if t.Price == priceID { |
| - return t, true |
| + return t, bandBase, true |
| + } |
| + for band, bd := range t.Bands { |
| + if bd.Price == priceID { |
| + return t, band, true |
| + } |
| } |
| } |
| - return billingTier{}, false |
| + return billingTier{}, "", false |
| +} |
| + |
| +// bandPrice resolves the Stripe price id + net revenue for one band of a tier. |
| +// The empty band means base. ok=false: the tier doesn't offer that band. |
| +func bandPrice(t billingTier, band string) (price string, net float64, ok bool) { |
| + if band == "" || band == bandBase { |
| + return t.Price, t.NetEURYear, true |
| + } |
| + bd, ok := t.Bands[band] |
| + if !ok { |
| + return "", 0, false |
| + } |
| + return bd.Price, bd.NetEURYear, true |
| } |
| // publicTiers is what listings expose: everything a picker needs. (Price ids |
| // aren't secret, but clients have no use for them — keys route instead.) |
| -func (b *stripeBilling) publicTiers() []map[string]string { |
| - out := make([]map[string]string, 0, len(b.tiers)) |
| +// Bands ride along as {band: {label, eur}} so the picker can render the honor |
| +// row without ever seeing a price id. |
| +func (b *stripeBilling) publicTiers() []map[string]any { |
| + out := make([]map[string]any, 0, len(b.tiers)) |
| for _, t := range b.tiers { |
| - out = append(out, map[string]string{"key": t.Key, "name": t.Name, "label": t.Label}) |
| + entry := map[string]any{"key": t.Key, "name": t.Name, "label": t.Label} |
| + if t.EUR > 0 { |
| + entry["eur"] = t.EUR |
| + } |
| + if t.Business { |
| + entry["business"] = true |
| + } |
| + if len(t.Bands) > 0 { |
| + bands := map[string]any{} |
| + for band, bd := range t.Bands { |
| + be := map[string]any{"label": bd.Label} |
| + if bd.EUR > 0 { |
| + be["eur"] = bd.EUR |
| + } |
| + bands[band] = be |
| + } |
| + entry["bands"] = bands |
| + } |
| + out = append(out, entry) |
| } |
| return out |
| } |
| @@ -117,6 +197,7 @@ func newStripeBilling(key, webhookSecret, pricesJSON, apiBase string) (*stripeBi |
| return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: no tiers configured") |
| } |
| seen := map[string]bool{} |
| + seenPrice := map[string]bool{} |
| for _, t := range tiers { |
| if t.Key == "" || t.Name == "" || t.Label == "" || t.Price == "" { |
| return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %+v is missing a field (need key, name, label, price)", t) |
| @@ -125,6 +206,24 @@ func newStripeBilling(key, webhookSecret, pricesJSON, apiBase string) (*stripeBi |
| return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: duplicate tier key %q", t.Key) |
| } |
| seen[t.Key] = true |
| + // A price id may appear once in the whole book — tierBandByPrice is how |
| + // webhooks attribute a subscription, so ambiguity here would misledger. |
| + if seenPrice[t.Price] { |
| + return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", t.Price) |
| + } |
| + seenPrice[t.Price] = true |
| + for band, bd := range t.Bands { |
| + if band == bandBase || !slices.Contains(bandKeys, band) { |
| + return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %q: unknown band %q (want %v; base is the tier's own price)", t.Key, band, bandKeys) |
| + } |
| + if bd.Label == "" || bd.Price == "" { |
| + return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %q band %q needs label and price", t.Key, band) |
| + } |
| + if seenPrice[bd.Price] { |
| + return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", bd.Price) |
| + } |
| + seenPrice[bd.Price] = true |
| + } |
| } |
| return &stripeBilling{ |
| apiBase: strings.TrimRight(apiBase, "/"), |
| @@ -149,6 +248,7 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| Requester string `json:"requester"` |
| Tier string `json:"tier"` |
| + Band string `json:"band"` |
| SuccessURL string `json:"success_url"` |
| CancelURL string `json:"cancel_url"` |
| } |
| @@ -166,6 +266,19 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier") |
| return |
| } |
| + // The honor band (docs/pricing-bands.md): whatever band arrives is |
| + // honoured, no eligibility test, ever. Default base; a band the book |
| + // doesn't offer for this tier is a client error, not a fallback — silently |
| + // charging base against a reduced-price link would betray the promise. |
| + band := strings.ToLower(strings.TrimSpace(req.Band)) |
| + if band == "" { |
| + band = bandBase |
| + } |
| + price, _, ok := bandPrice(tier, band) |
| + if !ok { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown band for this tier") |
| + return |
| + } |
| id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) |
| if err != nil { |
| writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| @@ -182,9 +295,9 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded") |
| return |
| } |
| - checkoutURL, err := s.billing.createCheckoutSession(id, tier, req.SuccessURL, req.CancelURL) |
| + checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL) |
| if err != nil { |
| - log.Printf("cloud: checkout for %s (id %d, tier %s): %v", host, id, tier.Key, err) |
| + log.Printf("cloud: checkout for %s (id %d, tier %s, band %s): %v", host, id, tier.Key, band, err) |
| writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| return |
| } |
| @@ -226,7 +339,7 @@ func (s *cloudServer) portalHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| return |
| } |
| - customer, err := s.db.spaceCustomer(id) |
| + customer, band, err := s.db.spaceCustomer(id) |
| if err != nil { |
| writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") |
| return |
| @@ -235,7 +348,7 @@ func (s *cloudServer) portalHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusConflict, "not_subscribed", "this space has no subscription to manage") |
| return |
| } |
| - portalURL, err := s.billing.createPortalSession(customer, req.ReturnURL) |
| + portalURL, err := s.billing.createPortalSession(customer, band, req.ReturnURL) |
| if err != nil { |
| log.Printf("cloud: portal for %s (id %d): %v", host, id, err) |
| writeControlErr(w, http.StatusBadGateway, "portal_failed", "couldn't open the billing page — please try again") |
| @@ -247,11 +360,16 @@ func (s *cloudServer) portalHandler(w http.ResponseWriter, r *http.Request) { |
| // createPortalSession asks Stripe for a hosted Billing Portal page. The |
| // optional portal configuration id (LCHAT_STRIPE_PORTAL_CONFIG) pins which |
| // dashboard-defined feature set the portal shows; unset uses Stripe's default. |
| -func (b *stripeBilling) createPortalSession(customer, returnURL string) (string, error) { |
| +// With bands, LCHAT_STRIPE_PORTAL_CONFIGS picks per-band (a portal only ever |
| +// offers plan switches inside the customer's own band — band changes are the |
| +// home surface's job, where they can be messaged). |
| +func (b *stripeBilling) createPortalSession(customer, band, returnURL string) (string, error) { |
| form := url.Values{} |
| form.Set("customer", customer) |
| form.Set("return_url", returnURL) |
| - if b.portalConfig != "" { |
| + if cfg, ok := b.portalConfigs[band]; ok && cfg != "" { |
| + form.Set("configuration", cfg) |
| + } else if b.portalConfig != "" { |
| form.Set("configuration", b.portalConfig) |
| } |
| req, err := http.NewRequest("POST", b.apiBase+"/v1/billing_portal/sessions", strings.NewReader(form.Encode())) |
| @@ -290,20 +408,32 @@ func validRedirectURL(raw string) bool { |
| // createCheckoutSession asks Stripe for a hosted Checkout page. The space's |
| // ledger id rides in client_reference_id (routes checkout.session.completed) |
| // and in the subscription's metadata (routes every later subscription event); |
| -// the tier key rides in the session metadata so completion records what was |
| -// bought. |
| -func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, successURL, cancelURL string) (string, error) { |
| +// the tier + band keys ride in the session metadata so completion records what |
| +// was bought. price is the band-resolved Stripe price id. |
| +func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string) (string, error) { |
| id := strconv.FormatInt(spaceID, 10) |
| form := url.Values{} |
| form.Set("mode", "subscription") |
| - form.Set("line_items[0][price]", tier.Price) |
| + form.Set("line_items[0][price]", price) |
| form.Set("line_items[0][quantity]", "1") |
| form.Set("client_reference_id", id) |
| form.Set("metadata[tier]", tier.Key) |
| + form.Set("metadata[band]", band) |
| form.Set("subscription_data[metadata][space_id]", id) |
| form.Set("subscription_data[metadata][tier]", tier.Key) |
| + form.Set("subscription_data[metadata][band]", band) |
| form.Set("success_url", successURL) |
| form.Set("cancel_url", cancelURL) |
| + // Stripe Tax (docs/pricing-bands.md § VAT): consumer Prices are created |
| + // tax-inclusive (the sticker is the total), Business tiers ex-VAT — so a |
| + // Business checkout also collects an optional VAT ID for the EU reverse |
| + // charge. Off until the operator flips the knob alongside the band Prices. |
| + if b.automaticTax { |
| + form.Set("automatic_tax[enabled]", "true") |
| + if tier.Business { |
| + form.Set("tax_id_collection[enabled]", "true") |
| + } |
| + } |
| req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) |
| if err != nil { |
| @@ -408,9 +538,9 @@ func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error { |
| log.Printf("cloud: webhook checkout.session.completed without a space reference (%q) — ignoring", obj.ClientReferenceID) |
| return nil |
| } |
| - switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"]); err { |
| + switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"], obj.Metadata["band"]); err { |
| case nil: |
| - log.Printf("cloud: space id %d upgraded (checkout complete, tier %s)", id, obj.Metadata["tier"]) |
| + log.Printf("cloud: space id %d upgraded (checkout complete, tier %s, band %s)", id, obj.Metadata["tier"], obj.Metadata["band"]) |
| s.pushQuotaByID(id) // raise the space's storage cap to its new tier |
| s.pushReplicationByID(id) // move it onto its tier's durability rung |
| return nil |
| @@ -470,22 +600,23 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage |
| } |
| } |
| paidUntil := obj.CurrentPeriodEnd |
| - // The subscription's current PRICE names the tier — a Billing Portal plan |
| - // switch changes the price but never the checkout-time metadata, so the |
| - // price is the truth and metadata only ever seeded the first value. |
| - tier := "" |
| + // The subscription's current PRICE names the tier and band — a Billing |
| + // Portal plan switch changes the price but never the checkout-time |
| + // metadata, so the price is the truth and metadata only ever seeded the |
| + // first value. |
| + tier, band := "", "" |
| for _, item := range obj.Items.Data { |
| if item.CurrentPeriodEnd > paidUntil { |
| paidUntil = item.CurrentPeriodEnd |
| } |
| - if t, ok := s.billing.tierByPrice(item.Price.ID); ok { |
| - tier = t.Key |
| + if t, b, ok := s.billing.tierBandByPrice(item.Price.ID); ok { |
| + tier, band = t.Key, b |
| } |
| } |
| - switch err := s.db.setEntitlementTier(id, ent, tier, paidUntil, obj.CancelAtPeriodEnd); err { |
| + switch err := s.db.setEntitlementTier(id, ent, tier, band, paidUntil, obj.CancelAtPeriodEnd); err { |
| case nil: |
| - log.Printf("cloud: space id %d entitlement → %s (tier %s, subscription %s, cancelling=%v)", id, ent, tier, obj.Status, obj.CancelAtPeriodEnd) |
| + log.Printf("cloud: space id %d entitlement → %s (tier %s, band %s, subscription %s, cancelling=%v)", id, ent, tier, band, obj.Status, obj.CancelAtPeriodEnd) |
| s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap |
| s.pushReplicationByID(id) // and the matching durability rung (lapse = back to free's) |
| return nil |
diff --git a/cloud.go b/cloud.go| index 93a8751..d22ca76 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -526,6 +526,16 @@ func runCloud(args []string) { |
| } |
| if billing != nil { |
| billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG") |
| + // Per-band portal configurations (docs/pricing-bands.md § Stripe |
| + // implementation) — optional JSON {band: configuration id}. |
| + if raw := os.Getenv("LCHAT_STRIPE_PORTAL_CONFIGS"); raw != "" { |
| + if err := json.Unmarshal([]byte(raw), &billing.portalConfigs); err != nil { |
| + die("cloud: LCHAT_STRIPE_PORTAL_CONFIGS: %v (want JSON {band: id})", err) |
| + } |
| + } |
| + // Stripe Tax rides an operator knob, flipped with the band-price |
| + // rollout (every live Price must resolve a tax_behavior first). |
| + billing.automaticTax = os.Getenv("LCHAT_STRIPE_AUTOMATIC_TAX") == "1" |
| } |
| poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS")) |
| if err != nil { |
diff --git a/cloud_bands_test.go b/cloud_bands_test.go| new file mode 100644 |
| index 0000000..03dc10a |
| --- /dev/null |
| +++ b/cloud_bands_test.go |
| @@ -0,0 +1,316 @@ |
| +package main |
| + |
| +// Pricing bands, Phase 0 (docs/pricing-bands.md): the price book's honor-band |
| +// dimension, the checkout band param, Stripe Tax plumbing, and the ledger's |
| +// band column. The invariant under test throughout: whatever band arrives is |
| +// honoured — priced exactly as the book says, recorded exactly as bought, |
| +// never silently substituted. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// withBands gives the harness book a band dimension: friends gains reduced, |
| +// business gains reduced + the business flag. family stays band-less to pin |
| +// backward compatibility. |
| +func withBands(s *cloudServer) { |
| + for i, t := range s.billing.tiers { |
| + switch t.Key { |
| + case "friends": |
| + s.billing.tiers[i].EUR = 4 |
| + s.billing.tiers[i].NetEURYear = 44 |
| + s.billing.tiers[i].Bands = map[string]billingBand{ |
| + "reduced": {Label: "€2/month", Price: "price_friends_m_red", EUR: 2, NetEURYear: 21}, |
| + } |
| + case "business": |
| + s.billing.tiers[i].Business = true |
| + s.billing.tiers[i].Bands = map[string]billingBand{ |
| + "reduced": {Label: "€49/month", Price: "price_business_m_red", EUR: 49}, |
| + } |
| + } |
| + } |
| +} |
| + |
| +// postCheckoutBand is postCheckout with an explicit band. |
| +func postCheckoutBand(t *testing.T, srv *httptest.Server, token, host, requester, tier, band string) *http.Response { |
| + t.Helper() |
| + body, _ := json.Marshal(map[string]string{ |
| + "requester": requester, |
| + "tier": tier, |
| + "band": band, |
| + "success_url": "https://home.example.com/?upgraded=" + host, |
| + "cancel_url": "https://home.example.com/", |
| + }) |
| + req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func spaceBandTier(t *testing.T, s *cloudServer, id int64) (tier, band string) { |
| + t.Helper() |
| + if err := s.db.sql.QueryRow(`SELECT tier, band FROM spaces WHERE id = ?`, id).Scan(&tier, &band); err != nil { |
| + t.Fatal(err) |
| + } |
| + return tier, band |
| +} |
| + |
| +func TestPriceBookBandValidation(t *testing.T) { |
| + mk := func(book string) error { |
| + _, err := newStripeBilling("sk", "whsec", book, "https://api.example.com") |
| + return err |
| + } |
| + // A banded book parses; a band-less one still does (backward compat). |
| + if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1", |
| + "bands":{"reduced":{"label":"€2.50/year","price":"p2"},"supporter":{"label":"€7/year","price":"p3"},"solidarity":{"label":"€1.25/year","price":"p4"}}}]`); err != nil { |
| + t.Fatalf("full band set: %v", err) |
| + } |
| + if err := mk(`[{"key":"family","name":"Family","label":"€5/year","price":"p1"}]`); err != nil { |
| + t.Fatalf("band-less book: %v", err) |
| + } |
| + // Unknown band names, base-as-a-band, incomplete bands, and any duplicated |
| + // price id are config mistakes that must refuse to boot. |
| + for name, book := range map[string]string{ |
| + "unknown band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"student":{"label":"l","price":"p2"}}}]`, |
| + "base as band": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"base":{"label":"l","price":"p2"}}}]`, |
| + "missing price": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l"}}}]`, |
| + "dup price across bands": `[{"key":"f","name":"F","label":"l","price":"p1","bands":{"reduced":{"label":"l","price":"p1"}}}]`, |
| + "dup price across tiers": `[{"key":"f","name":"F","label":"l","price":"p1"},{"key":"g","name":"G","label":"l","price":"p1"}]`, |
| + } { |
| + if err := mk(book); err == nil { |
| + t.Errorf("%s: parsed, want error", name) |
| + } |
| + } |
| +} |
| + |
| +func TestTierBandByPrice(t *testing.T) { |
| + _, s, _ := billingTestCloud(t) |
| + withBands(s) |
| + if tier, band, ok := s.billing.tierBandByPrice("price_friends_m"); !ok || tier.Key != "friends" || band != "base" { |
| + t.Fatalf("base price → %s/%s ok=%v", tier.Key, band, ok) |
| + } |
| + if tier, band, ok := s.billing.tierBandByPrice("price_friends_m_red"); !ok || tier.Key != "friends" || band != "reduced" { |
| + t.Fatalf("band price → %s/%s ok=%v", tier.Key, band, ok) |
| + } |
| + if _, _, ok := s.billing.tierBandByPrice("price_unknown"); ok { |
| + t.Fatal("unknown price resolved") |
| + } |
| +} |
| + |
| +func TestCheckoutBand(t *testing.T) { |
| + srv, s, captured := billingTestCloud(t) |
| + withBands(s) |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + |
| + // A band checkout charges the band's own Price and stamps band metadata in |
| + // both routing slots. |
| + if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "reduced"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("reduced checkout: %d", resp.StatusCode) |
| + } |
| + f := *captured |
| + if f.Get("line_items[0][price]") != "price_friends_m_red" { |
| + t.Fatalf("price = %q, want the reduced band's", f.Get("line_items[0][price]")) |
| + } |
| + if f.Get("metadata[band]") != "reduced" || f.Get("subscription_data[metadata][band]") != "reduced" { |
| + t.Fatalf("band metadata: session=%q sub=%q", f.Get("metadata[band]"), f.Get("subscription_data[metadata][band]")) |
| + } |
| + // Stripe Tax is an operator knob — absent until flipped. |
| + if f.Has("automatic_tax[enabled]") || f.Has("tax_id_collection[enabled]") { |
| + t.Fatalf("tax fields sent with the knob off: %v", f) |
| + } |
| + |
| + // No band = base, priced and stamped as such. |
| + if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("base checkout: %d", resp.StatusCode) |
| + } |
| + f = *captured |
| + if f.Get("line_items[0][price]") != "price_friends_m" || f.Get("metadata[band]") != "base" { |
| + t.Fatalf("base checkout form: price=%q band=%q", f.Get("line_items[0][price]"), f.Get("metadata[band]")) |
| + } |
| + |
| + // A band this tier doesn't offer is refused — never silently re-priced. |
| + if resp := postCheckoutBand(t, srv, tok, host, "user-1", "friends", "solidarity"); resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("unoffered band: %d, want 400", resp.StatusCode) |
| + } |
| + // A band on a band-less tier likewise (family has none). |
| + if resp := postCheckoutBand(t, srv, tok, host, "user-1", "family", "reduced"); resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("band on band-less tier: %d, want 400", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestCheckoutStripeTax(t *testing.T) { |
| + srv, s, captured := billingTestCloud(t) |
| + withBands(s) |
| + s.billing.automaticTax = true |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + |
| + // Consumer tier: tax on, no VAT-ID collection. |
| + if resp := postCheckout(t, srv, tok, host, "user-1", "friends"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("friends checkout: %d", resp.StatusCode) |
| + } |
| + f := *captured |
| + if f.Get("automatic_tax[enabled]") != "true" || f.Has("tax_id_collection[enabled]") { |
| + t.Fatalf("consumer tax fields: %v", f) |
| + } |
| + // Business tier: tax on AND the VAT ID field (EU reverse charge). |
| + if resp := postCheckout(t, srv, tok, host, "user-1", "business"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("business checkout: %d", resp.StatusCode) |
| + } |
| + f = *captured |
| + if f.Get("automatic_tax[enabled]") != "true" || f.Get("tax_id_collection[enabled]") != "true" { |
| + t.Fatalf("business tax fields: %v", f) |
| + } |
| +} |
| + |
| +func TestWebhookRecordsBand(t *testing.T) { |
| + srv, s, _ := billingTestCloud(t) |
| + withBands(s) |
| + tok := mintCaller(t, srv, "home") |
| + createSpace(t, srv, tok, "user-1") |
| + |
| + deliver := func(event string) { |
| + t.Helper() |
| + payload := []byte(event) |
| + if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { |
| + t.Fatalf("webhook: %d", r.StatusCode) |
| + } |
| + } |
| + |
| + // Checkout completion seeds tier + band from the session metadata. |
| + deliver(`{"type":"checkout.session.completed","data":{"object":{ |
| + "mode":"subscription","client_reference_id":"1","customer":"cus_1","subscription":"sub_1", |
| + "metadata":{"tier":"friends","band":"reduced"}}}}`) |
| + if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "reduced" { |
| + t.Fatalf("after checkout: %s/%s, want friends/reduced", tier, band) |
| + } |
| + |
| + // A plan switch to another band's price moves the recorded band — the |
| + // subscription's PRICE is the truth, metadata only seeded the first value. |
| + deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| + "id":"sub_1","status":"active","metadata":{"space_id":"1","tier":"friends","band":"reduced"}, |
| + "items":{"data":[{"current_period_end":1900000000,"price":{"id":"price_friends_m"}}]}}}}`) |
| + if tier, band := spaceBandTier(t, s, 1); tier != "friends" || band != "base" { |
| + t.Fatalf("after switch to base price: %s/%s, want friends/base", tier, band) |
| + } |
| + |
| + // An event without price data keeps the recorded band (COALESCE semantics). |
| + deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| + "id":"sub_1","status":"active","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) |
| + if _, band := spaceBandTier(t, s, 1); band != "base" { |
| + t.Fatalf("band lost on a price-less event: %q", band) |
| + } |
| +} |
| + |
| +func TestPortalBandConfig(t *testing.T) { |
| + // A portal fake capturing the configuration param. |
| + captured := url.Values{} |
| + stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/v1/billing_portal/sessions" { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + _ = r.ParseForm() |
| + captured = r.PostForm |
| + writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"}) |
| + })) |
| + defer stripe.Close() |
| + |
| + srv, s, _ := billingTestCloud(t) |
| + withBands(s) |
| + s.billing.apiBase = stripe.URL |
| + s.billing.portalConfig = "bpc_default" |
| + s.billing.portalConfigs = map[string]string{"reduced": "bpc_reduced"} |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + if err := s.db.markSpacePaid(1, "cus_1", "sub_1", "friends", "reduced"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + postPortal := func() { |
| + t.Helper() |
| + body, _ := json.Marshal(map[string]string{"requester": "user-1", "return_url": "https://home.example.com/"}) |
| + req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("portal: %d", resp.StatusCode) |
| + } |
| + } |
| + |
| + // The reduced-band space gets the reduced portal configuration… |
| + postPortal() |
| + if got := captured.Get("configuration"); got != "bpc_reduced" { |
| + t.Fatalf("configuration = %q, want bpc_reduced", got) |
| + } |
| + // …and a band without its own config falls back to the default. |
| + if _, err := s.db.sql.Exec(`UPDATE spaces SET band = 'supporter' WHERE id = 1`); err != nil { |
| + t.Fatal(err) |
| + } |
| + postPortal() |
| + if got := captured.Get("configuration"); got != "bpc_default" { |
| + t.Fatalf("fallback configuration = %q, want bpc_default", got) |
| + } |
| +} |
| + |
| +func TestPublicTiersBands(t *testing.T) { |
| + _, s, _ := billingTestCloud(t) |
| + withBands(s) |
| + out := s.billing.publicTiers() |
| + var friends map[string]any |
| + for _, e := range out { |
| + if e["key"] == "friends" { |
| + friends = e |
| + } |
| + } |
| + if friends == nil { |
| + t.Fatal("friends tier missing from listing") |
| + } |
| + if friends["eur"] != 4.0 { |
| + t.Fatalf("eur = %v", friends["eur"]) |
| + } |
| + bands, _ := friends["bands"].(map[string]any) |
| + red, _ := bands["reduced"].(map[string]any) |
| + if red == nil || red["label"] != "€2/month" || red["eur"] != 2.0 { |
| + t.Fatalf("reduced band listing = %v", red) |
| + } |
| + // Price ids never cross the public boundary. |
| + blob, _ := json.Marshal(out) |
| + if strings.Contains(string(blob), "price_") { |
| + t.Fatalf("listing leaks a price id: %s", blob) |
| + } |
| +} |
| + |
| +func TestNetForTierBand(t *testing.T) { |
| + _, s, _ := billingTestCloud(t) |
| + withBands(s) |
| + if net := s.netForTier("friends", "base"); net != 44 { |
| + t.Fatalf("base net = %v", net) |
| + } |
| + if net := s.netForTier("friends", ""); net != 44 { |
| + t.Fatalf("empty-band net = %v (pre-band rows read as base)", net) |
| + } |
| + if net := s.netForTier("friends", "reduced"); net != 21 { |
| + t.Fatalf("reduced net = %v", net) |
| + } |
| + // A recorded band the book no longer names falls back to base, not zero. |
| + if net := s.netForTier("friends", "supporter"); net != 44 { |
| + t.Fatalf("retired-band net = %v", net) |
| + } |
| +} |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index 08b413c..1725245 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -171,7 +171,7 @@ func TestCheckoutGates(t *testing.T) { |
| // Already upgraded → 409. |
| label, _, _ := strings.Cut(host, ".") |
| id, _ := spaceID(label) |
| - if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends"); err != nil { |
| + if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { |
| t.Fatal(err) |
| } |
| if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusConflict { |
| @@ -354,7 +354,7 @@ func TestReaperNeverSeesPaidSpaces(t *testing.T) { |
| old := now.Add(-20 * 24 * time.Hour).Unix() |
| paidID := insertSpaceRow(t, db, "paid.11ch.at", old) |
| freeID := insertSpaceRow(t, db, "free.11ch.at", old) |
| - if err := db.markSpacePaid(paidID, "cus_1", "sub_1", "friends"); err != nil { |
| + if err := db.markSpacePaid(paidID, "cus_1", "sub_1", "friends", ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -447,7 +447,7 @@ func TestQuotaExemptsPaidSpaces(t *testing.T) { |
| // Upgrade the first space → its slot frees → creation works again. |
| label, _, _ := strings.Cut(firstHost, ".") |
| id, _ := spaceID(label) |
| - if err := s.db.markSpacePaid(id, "cus_1", "sub_1", "friends"); err != nil { |
| + if err := s.db.markSpacePaid(id, "cus_1", "sub_1", "friends", ""); err != nil { |
| t.Fatal(err) |
| } |
| if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { |
| @@ -541,7 +541,7 @@ func TestPortalSession(t *testing.T) { |
| // Upgrade it, then the portal link mints against its customer. |
| label, _, _ := strings.Cut(host, ".") |
| id, _ := spaceID(label) |
| - if err := s.db.markSpacePaid(id, "cus_9", "sub_9", "family"); err != nil { |
| + if err := s.db.markSpacePaid(id, "cus_9", "sub_9", "family", ""); err != nil { |
| t.Fatal(err) |
| } |
| resp := post("user-1") |
diff --git a/cloud_db.go b/cloud_db.go| index 8265404..5c4464b 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -318,6 +318,15 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n |
| return err |
| } |
| } |
| + // band: which honor price the subscription is on (docs/pricing-bands.md) — |
| + // base/supporter/reduced/solidarity. '' on pre-band rows reads as base. |
| + if has, err := db.spacesHasColumn("band"); err != nil { |
| + return err |
| + } else if !has { |
| + if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN band TEXT NOT NULL DEFAULT ''`); err != nil { |
| + return err |
| + } |
| + } |
| return nil |
| } |
| @@ -687,10 +696,10 @@ func (db *cloudDB) spaceEntitlement(id int64) (string, error) { |
| // bought, plus the paid flag (and a fresh subscription is by definition not |
| // cancelling). paid_until is left alone — the subscription event that follows |
| // the checkout carries the period end (see handleSubscriptionEvent). |
| -func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID, tier string) error { |
| +func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID, tier, band string) error { |
| res, err := db.sql.Exec( |
| - `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, tier = ?, entitlement = ?, cancel_at_period_end = 0 WHERE id = ?`, |
| - customerID, subscriptionID, tier, entPaid, id, |
| + `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, tier = ?, band = ?, entitlement = ?, cancel_at_period_end = 0 WHERE id = ?`, |
| + customerID, subscriptionID, tier, band, entPaid, id, |
| ) |
| if err != nil { |
| return err |
| @@ -703,16 +712,16 @@ func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID, tier stri |
| // setEntitlement flips a space's entitlement + subscription period end. |
| func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error { |
| - return db.setEntitlementTier(id, ent, "", paidUntil, false) |
| + return db.setEntitlementTier(id, ent, "", "", paidUntil, false) |
| } |
| -// setEntitlementTier additionally moves the tier when one is known ("" keeps |
| -// the recorded tier — e.g. an event without price data) and records whether |
| +// setEntitlementTier additionally moves the tier + band when known ("" keeps |
| +// the recorded value — e.g. an event without price data) and records whether |
| // the subscription is set to run out at paid_until. |
| -func (db *cloudDB) setEntitlementTier(id int64, ent, tier string, paidUntil int64, cancelAtEnd bool) error { |
| +func (db *cloudDB) setEntitlementTier(id int64, ent, tier, band string, paidUntil int64, cancelAtEnd bool) error { |
| res, err := db.sql.Exec( |
| - `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier), cancel_at_period_end = ? WHERE id = ?`, |
| - ent, paidUntil, tier, cancelAtEnd, id, |
| + `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier), band = COALESCE(NULLIF(?, ''), band), cancel_at_period_end = ? WHERE id = ?`, |
| + ent, paidUntil, tier, band, cancelAtEnd, id, |
| ) |
| if err != nil { |
| return err |
| @@ -777,7 +786,7 @@ CREATE TABLE IF NOT EXISTS pool_meter ( |
| // doesn't know the price book; the sweep resolves it. |
| func (db *cloudDB) poolRows() ([]poolRow, error) { |
| rows, err := db.sql.Query( |
| - `SELECT entitlement, tier, storage_bytes FROM spaces WHERE host IS NOT NULL`) |
| + `SELECT entitlement, tier, band, storage_bytes FROM spaces WHERE host IS NOT NULL`) |
| if err != nil { |
| return nil, err |
| } |
| @@ -785,7 +794,7 @@ func (db *cloudDB) poolRows() ([]poolRow, error) { |
| var out []poolRow |
| for rows.Next() { |
| var r poolRow |
| - if err := rows.Scan(&r.Entitlement, &r.Tier, &r.StorageBytes); err != nil { |
| + if err := rows.Scan(&r.Entitlement, &r.Tier, &r.Band, &r.StorageBytes); err != nil { |
| return nil, err |
| } |
| out = append(out, r) |
| @@ -851,16 +860,17 @@ func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) { |
| return m, err |
| } |
| -// spaceCustomer reads a space's opaque Stripe customer id ("" = never paid). |
| -func (db *cloudDB) spaceCustomer(id int64) (string, error) { |
| +// spaceCustomer reads a space's opaque Stripe customer id ("" = never paid) |
| +// plus its recorded band — the portal path picks a per-band configuration. |
| +func (db *cloudDB) spaceCustomer(id int64) (customer, band string, err error) { |
| var c *string |
| - if err := db.sql.QueryRow(`SELECT stripe_customer_id FROM spaces WHERE id = ?`, id).Scan(&c); err != nil { |
| - return "", err |
| + if err := db.sql.QueryRow(`SELECT stripe_customer_id, band FROM spaces WHERE id = ?`, id).Scan(&c, &band); err != nil { |
| + return "", "", err |
| } |
| if c == nil { |
| - return "", nil |
| + return "", band, nil |
| } |
| - return *c, nil |
| + return *c, band, nil |
| } |
| // setQuotaBytes records what the cloud last pushed into an instance's env. |
diff --git a/cloudreplication_test.go b/cloudreplication_test.go| index 66218b5..377c40d 100644 |
| --- a/cloudreplication_test.go |
| +++ b/cloudreplication_test.go |
| @@ -110,7 +110,7 @@ func TestReplicationPushOnUpgradeAndSweep(t *testing.T) { |
| } |
| // The upgrade path (what the checkout webhook calls). |
| - if err := db.markSpacePaid(paidID, "cus", "sub", "business"); err != nil { |
| + if err := db.markSpacePaid(paidID, "cus", "sub", "business", ""); err != nil { |
| t.Fatal(err) |
| } |
| s.pushReplicationByID(paidID) |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 4a319c9..92fa580 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -552,7 +552,19 @@ LCHAT_STRIPE_PRICES the price book: a JSON array of tiers, e.g. |
| (display strings live here verbatim — the server never |
| asks Stripe what a price costs, so a Stripe outage can't |
| block a cloud restart; the checkout charges the real price) |
| + Optional per-tier: "eur" (numeric sticker, feeds the |
| + picker's ≈ local-currency display), "business": true |
| + (ex-VAT tier: checkout collects a VAT ID), and "bands" |
| + — the honor prices of docs/pricing-bands.md: |
| + {"supporter"|"reduced"|"solidarity": |
| + {"label","price","eur","net_eur_year"}}. |
| + A tier without bands behaves exactly as before; a |
| + price id may appear only once in the whole book. |
| LCHAT_STRIPE_API_BASE default https://api.stripe.com (tests point it at a stub) |
| +LCHAT_STRIPE_AUTOMATIC_TAX "1" opts Checkout Sessions into Stripe Tax |
| + (docs/pricing-bands.md § VAT). Flip it WITH the |
| + band-price rollout — every live Price must carry a |
| + tax_behavior (or an account default) first. |
| ``` |
| Unset = billing off: checkout answers 501, listings say `billing: false`, the |
| @@ -575,7 +587,11 @@ construction — every space is a separate Stripe customer, deliberately (no |
| cross-space identity exists to merge). `LCHAT_STRIPE_PORTAL_CONFIG` optionally |
| pins a portal configuration id; unset uses the account's default (which must |
| exist — save the portal settings once in the Stripe dashboard, or create a |
| -configuration via the API). |
| +configuration via the API). With bands, `LCHAT_STRIPE_PORTAL_CONFIGS` |
| +(JSON `{band: configuration id}`) picks a per-band configuration — each |
| +band's portal offers plan switches inside that band only; band changes go |
| +through the home surface, where they can be messaged. Bands without an entry |
| +fall back to `LCHAT_STRIPE_PORTAL_CONFIG`. |
| ## Enabling it on a box |
diff --git a/meter_test.go b/meter_test.go| index 9dd2136..2be06b9 100644 |
| --- a/meter_test.go |
| +++ b/meter_test.go |
| @@ -194,7 +194,7 @@ func TestQuotaPushOnUpgradeAndSweep(t *testing.T) { |
| } |
| // The upgrade path (what the checkout webhook calls). |
| - if err := db.markSpacePaid(paidID, "cus", "sub", "family"); err != nil { |
| + if err := db.markSpacePaid(paidID, "cus", "sub", "family", ""); err != nil { |
| t.Fatal(err) |
| } |
| s.pushQuotaByID(paidID) |
diff --git a/pool.go b/pool.go| index 3fc9b3f..e86ffc3 100644 |
| --- a/pool.go |
| +++ b/pool.go |
| @@ -98,10 +98,12 @@ func (c poolConfig) enabled() bool { return c.maxShare > 0 } |
| // poolRow is one ledger space as the budget math sees it. NetEURYear is |
| // resolved from the price book by the caller (0 for free/demo rows and for |
| -// tiers whose price-book entry carries no net_eur_year). |
| +// tiers whose price-book entry carries no net_eur_year); Band picks which of |
| +// the tier's honor prices the subscription actually pays. |
| type poolRow struct { |
| Entitlement string |
| Tier string |
| + Band string |
| StorageBytes int64 |
| NetEURYear float64 |
| } |
| @@ -198,14 +200,21 @@ func poolState(pressure float64) string { |
| // netForTier resolves a paid space's net revenue from the price book's |
| // additive net_eur_year field (0 when billing is off or the entry has none). |
| -func (s *cloudServer) netForTier(tier string) float64 { |
| +// The band picks the honor price actually paid — a Reduced subscription must |
| +// feed the pool budget its real net, not base's (docs/pricing-bands.md). |
| +func (s *cloudServer) netForTier(tier, band string) float64 { |
| if s.billing == nil { |
| return 0 |
| } |
| - if t, ok := s.billing.tier(tier); ok { |
| - return t.NetEURYear |
| + t, ok := s.billing.tier(tier) |
| + if !ok { |
| + return 0 |
| + } |
| + if _, net, ok := bandPrice(t, band); ok { |
| + return net |
| } |
| - return 0 |
| + // A recorded band the book no longer names: base's figure beats zero. |
| + return t.NetEURYear |
| } |
| // poolSweep computes and records one gauge reading from the ledger. Called at |
| @@ -219,7 +228,7 @@ func (s *cloudServer) poolSweep(now time.Time) { |
| } |
| for i := range rows { |
| if rows[i].Entitlement == entPaid { |
| - rows[i].NetEURYear = s.netForTier(rows[i].Tier) |
| + rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band) |
| } |
| } |
| r := computePool(s.pool, rows) |