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>
4 files changed,
+51
−5
billing.go+25 −3cloud_billing_test.go+9 −0cloud_db.go+8 −1docs/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 |