eleven / messenger Public

server: plan switching — tier follows the subscription's price (#694)

A Billing Portal plan switch changes the subscription's price, never its
checkout-time metadata — so the webhook now derives the tier from the
current price (tierByPrice), metadata only seeding the first value. The
badge and the pushed storage cap follow a switch automatically. Docs
record switch + cancel semantics.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e63f91a621930ea8a52a960b383956e8d2101b36 parent ad3838a
4 files changed, +51 −5
  • billing.go +25 −3
  • cloud_billing_test.go +9 −0
  • cloud_db.go +8 −1
  • docs/cloud-provisioner.md +9 −1
diff --git a/billing.go b/billing.go
index 0d24c07..41ace43 100644
--- a/billing.go
+++ b/billing.go
@@ -71,6 +71,18 @@ 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) {
+ for _, t := range b.tiers {
+ if t.Price == priceID {
+ return t, true
+ }
+ }
+ return billingTier{}, false
+}
+
// 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 {
@@ -419,6 +431,9 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage
Items struct {
Data []struct {
CurrentPeriodEnd int64 `json:"current_period_end"`
+ Price struct {
+ ID string `json:"id"`
+ } `json:"price"`
} `json:"data"`
} `json:"items"`
}
@@ -447,16 +462,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 := ""
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
+ }
}
- switch err := s.db.setEntitlement(id, ent, paidUntil); err {
+ switch err := s.db.setEntitlementTier(id, ent, tier, paidUntil); err {
case nil:
- log.Printf("cloud: space id %d entitlement → %s (subscription %s)", id, ent, obj.Status)
- s.pushQuotaByID(id) // entitlement moved — re-apply the matching storage cap
+ log.Printf("cloud: space id %d entitlement → %s (tier %s, subscription %s)", id, ent, tier, obj.Status)
+ s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id)
diff --git a/cloud_billing_test.go b/cloud_billing_test.go
index 5e829c5..3c7a79a 100644
--- a/cloud_billing_test.go
+++ b/cloud_billing_test.go
@@ -261,6 +261,15 @@ func TestWebhookEntitlementLifecycle(t *testing.T) {
t.Fatalf("fallback routing: paid_until = %d, want 1900000099", got.PaidUntil)
}
+ // A Billing Portal plan switch arrives as an update with a NEW price and
+ // the ORIGINAL metadata — the price wins: tier follows it.
+ deliver(`{"type":"customer.subscription.updated","data":{"object":{
+ "id":"sub_9","status":"active","metadata":{"space_id":"1","tier":"family"},
+ "items":{"data":[{"current_period_end":1900000200,"price":{"id":"price_business_m"}}]}}}}`)
+ if got := entitlement(); got.Tier != "business" || got.PaidUntil != 1900000200 {
+ t.Fatalf("after plan switch: %+v, want tier business", got)
+ }
+
// Dunning keeps the space paid; a dead subscription drops it to free.
deliver(`{"type":"customer.subscription.updated","data":{"object":{
"id":"sub_9","status":"past_due","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`)
diff --git a/cloud_db.go b/cloud_db.go
index 994683d..adab3f6 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -443,8 +443,15 @@ 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)
+}
+
+// setEntitlementTier additionally moves the tier when one is known ("" keeps
+// the recorded tier — e.g. an event without price data).
+func (db *cloudDB) setEntitlementTier(id int64, ent, tier string, paidUntil int64) error {
res, err := db.sql.Exec(
- `UPDATE spaces SET entitlement = ?, paid_until = ? WHERE id = ?`, ent, paidUntil, id,
+ `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier) WHERE id = ?`,
+ ent, paidUntil, tier, id,
)
if err != nil {
return err
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 87cc94e..5e656af 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -396,7 +396,15 @@ UI shows nothing. The Stripe-side webhook endpoint must subscribe to
**Managing a subscription** is Stripe's hosted **Billing Portal**: Manage on a
paid space's row → home → cloud mints a portal session for that space's own
-Stripe customer (same ownership gate as checkout). Portals are per-space by
+Stripe customer (same ownership gate as checkout). The portal also offers
+**plan switching** (upgrade/downgrade, monthly↔annual, prorated by Stripe);
+the webhook derives the tier from the subscription's current PRICE (metadata
+only seeds the first value), so the badge and the pushed storage cap follow a
+switch automatically. **Cancelling** is at-period-end: the space keeps its
+tier until the paid time runs out, then the deletion event drops it to free —
+the space survives, its storage cap returns to the free allowance (existing
+content is never deleted; only new uploads pause if over), and it counts
+toward the creation quota again. Portals are per-space by
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