eleven / messenger Public

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>
Paul Campbell 956bf8776adc1c8e3c7fd62ce6bee2b143db858a parent 66c92c5
5 files changed, +54 −15
  • billing.go +6 −5
  • cloud_billing_test.go +15 −0
  • cloud_db.go +23 −10
  • home/web/pages/index.html +2 −0
  • home/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);
}