eleven / messenger Public

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>
Paul Campbell e8b5f76644da6aa104bc45a8ffd917d3e3834844 parent 2c7a493
9 files changed, +552 −60
  • billing.go +161 −30
  • cloud.go +10 −0
  • cloud_bands_test.go +316 −0
  • cloud_billing_test.go +4 −4
  • cloud_db.go +27 −17
  • cloudreplication_test.go +1 −1
  • docs/cloud-provisioner.md +17 −1
  • meter_test.go +1 −1
  • pool.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)