server+home: show a cancelled subscription's end date on the row (#699)
The webhook records cancel_at_period_end (cleared on resume and on a fresh checkout); the listing carries it; home's badge row reads 'FAMILY · Manage · ends 11 Jul 2027'. In-space notices stay deliberately absent — the subscription is the payer's business, not the members'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+54
−15
billing.go+6 −5cloud_billing_test.go+15 −0cloud_db.go+23 −10home/web/pages/index.html+2 −0home/web/static/home.js+8 −0
diff --git a/billing.go b/billing.go| index 41ace43..1e14570 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -422,9 +422,10 @@ func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error { |
| // (backup-link mail via Stripe) is later work, by design. |
| func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage) error { |
| var obj struct { |
| - ID string `json:"id"` |
| - Status string `json:"status"` |
| - Metadata map[string]string `json:"metadata"` |
| + ID string `json:"id"` |
| + Status string `json:"status"` |
| + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` |
| + Metadata map[string]string `json:"metadata"` |
| // Stripe API versions ≥2025-03-31 moved current_period_end from the |
| // subscription to its items; accept either shape. |
| CurrentPeriodEnd int64 `json:"current_period_end"` |
| @@ -475,9 +476,9 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage |
| } |
| } |
| - switch err := s.db.setEntitlementTier(id, ent, tier, paidUntil); err { |
| + switch err := s.db.setEntitlementTier(id, ent, tier, paidUntil, obj.CancelAtPeriodEnd); err { |
| case nil: |
| - log.Printf("cloud: space id %d entitlement → %s (tier %s, subscription %s)", id, ent, tier, obj.Status) |
| + log.Printf("cloud: space id %d entitlement → %s (tier %s, subscription %s, cancelling=%v)", id, ent, tier, obj.Status, obj.CancelAtPeriodEnd) |
| s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap |
| return nil |
| case sql.ErrNoRows: |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index 3c7a79a..08b413c 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -270,6 +270,21 @@ func TestWebhookEntitlementLifecycle(t *testing.T) { |
| t.Fatalf("after plan switch: %+v, want tier business", got) |
| } |
| + // The payer cancels (portal, at period end): still paid, but the listing |
| + // says the subscription runs out — and resuming clears it. |
| + deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| + "id":"sub_9","status":"active","cancel_at_period_end":true,"metadata":{"space_id":"1"}, |
| + "current_period_end":1900000300}}}`) |
| + if got := entitlement(); got.Entitlement != entPaid || !got.CancelAtPeriodEnd || got.PaidUntil != 1900000300 { |
| + t.Fatalf("after cancel: %+v, want paid + cancelling at 1900000300", got) |
| + } |
| + deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| + "id":"sub_9","status":"active","cancel_at_period_end":false,"metadata":{"space_id":"1"}, |
| + "current_period_end":1900000300}}}`) |
| + if got := entitlement(); got.CancelAtPeriodEnd { |
| + t.Fatalf("after resume: %+v, want cancelling cleared", 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 adab3f6..34dd35f 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -165,6 +165,15 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n |
| return err |
| } |
| } |
| + // cancel_at_period_end: the payer has cancelled and the subscription runs |
| + // out at paid_until — home shows "ends <date>" on the row. |
| + if has, err := db.spacesHasColumn("cancel_at_period_end"); err != nil { |
| + return err |
| + } else if !has { |
| + if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN cancel_at_period_end INTEGER NOT NULL DEFAULT 0`); err != nil { |
| + return err |
| + } |
| + } |
| return nil |
| } |
| @@ -378,6 +387,8 @@ type Space struct { |
| Entitlement string `json:"entitlement"` |
| Tier string `json:"tier,omitempty"` |
| PaidUntil int64 `json:"paid_until,omitempty"` |
| + // The payer cancelled; the subscription (and tier) runs out at PaidUntil. |
| + CancelAtPeriodEnd bool `json:"cancel_at_period_end,omitempty"` |
| // The storage meter's last reading (bytes of ciphertext; 0 = not yet |
| // metered), when it was taken, and the cap last pushed to the instance |
| // (0 = none — the instance's compiled default applies). |
| @@ -390,7 +401,7 @@ type Space struct { |
| // still in flight or one that failed before rollback — not shown). |
| func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| rows, err := db.sql.Query( |
| - `SELECT host, created_at, entitlement, tier, paid_until, storage_bytes, metered_at, quota_bytes FROM spaces |
| + `SELECT host, created_at, entitlement, tier, paid_until, cancel_at_period_end, storage_bytes, metered_at, quota_bytes FROM spaces |
| WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`, |
| callerID, requester, |
| ) |
| @@ -401,7 +412,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| out := []Space{} |
| for rows.Next() { |
| var s Space |
| - if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.StorageBytes, &s.MeteredAt, &s.StorageCap); err != nil { |
| + if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.CancelAtPeriodEnd, &s.StorageBytes, &s.MeteredAt, &s.StorageCap); err != nil { |
| return nil, err |
| } |
| s.URL = "https://" + s.Host |
| @@ -425,11 +436,12 @@ func (db *cloudDB) spaceEntitlement(id int64) (string, error) { |
| } |
| // markSpacePaid records a completed checkout: the opaque Stripe ids, the tier |
| -// bought, plus the paid flag. paid_until is left alone — the subscription event |
| -// that follows the checkout carries the period end (see handleSubscriptionEvent). |
| +// 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 { |
| res, err := db.sql.Exec( |
| - `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, tier = ?, entitlement = ? WHERE id = ?`, |
| + `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, tier = ?, entitlement = ?, cancel_at_period_end = 0 WHERE id = ?`, |
| customerID, subscriptionID, tier, entPaid, id, |
| ) |
| if err != nil { |
| @@ -443,15 +455,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) |
| + 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). |
| -func (db *cloudDB) setEntitlementTier(id int64, ent, tier string, paidUntil int64) error { |
| +// the recorded tier — 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 { |
| res, err := db.sql.Exec( |
| - `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier) WHERE id = ?`, |
| - ent, paidUntil, tier, id, |
| + `UPDATE spaces SET entitlement = ?, paid_until = ?, tier = COALESCE(NULLIF(?, ''), tier), cancel_at_period_end = ? WHERE id = ?`, |
| + ent, paidUntil, tier, cancelAtEnd, id, |
| ) |
| if err != nil { |
| return err |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index c18e601..12592f2 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -255,6 +255,8 @@ |
| position: relative; |
| } |
| .row-manage::before { content: ""; position: absolute; inset: -6px; border-radius: 999px; } |
| + /* "ends 11 Jul 2027" beside a cancelled subscription's badge. */ |
| + .sub-ends { color: var(--muted); font-size: .72rem; } |
| .row-manage:hover { color: var(--accent); border-color: var(--accent); } |
| .row-manage:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| .row-manage:disabled { opacity: .4; cursor: default; } |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 0fe36cf..021ad47 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -478,6 +478,14 @@ function serverRow(s, sortable) { |
| manage.setAttribute("aria-label", "Manage the subscription for " + labelOf(s)); |
| manage.addEventListener("click", () => openPortal(hostOf(s.url), manage)); |
| row.append(badge, manage); |
| + // A cancelled subscription runs out at paid_until — say so, quietly. |
| + if (ledger.cancel_at_period_end && ledger.paid_until) { |
| + const ends = document.createElement("span"); |
| + ends.className = "sub-ends"; |
| + ends.textContent = "ends " + new Date(ledger.paid_until * 1000) |
| + .toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); |
| + row.append(ends); |
| + } |
| main.append(row); |
| } |