eleven / messenger Public

Paid access levels: member subscriptions via Stripe Connect (assigner #3) (#703)

* server: paid access levels — member subscriptions via Stripe Connect (assigner #3)

Entitlement engine on user_tiers (status/paid_until/grace_deadline/ext_sub),
tiers gain price config. Payment behind a PayProvider seam; stripeConnect
charges the admin's connected account with a 10% application fee (hand-rolled
like billing.go, reusing verifyStripeSignature). Member endpoints: subscribe/
change/cancel/portal; admin: set price (auto-mints a Stripe Price), link
account. Webhook drives entitlement → assignTier(...'stripe')+key prod on
active, unassignTier after the 30-day grace (hourly sweep + inline revoke).
Dormant until LCHAT_STRIPE_CONNECT_* + a linked account. Tests cover the
state machine, sweep, webhook activation/migration, and the HTTP surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Membership UI (subscribe/change/cancel, dated access-ends note) + admin price/account

The member-facing surface for paid access levels, designed via the
frontend-design skill: the current subscription with a plain dated status line
('Renews on …' / 'Your access ends on …' / a warned 'payment didn't go through
— access ends …'), change level, and cancel — cancel is a dated promise ('keep
Gold until 5 February'), never a threat; price + interval shown before any
commitment; state never carried by colour alone. Reachable from Edit profile +
⌘K when the space sells any level or you subscribe. Admin: per-level 'set a
price' + link-Stripe-account in the Access levels section (shown only once
payments are configured). Changelog + design-doc status updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: native Membership view for paid access levels

The native twin of the web's showMembership (shell.js): members see the
access levels they can subscribe to, their current subscription and its
exact dated state, and can change level, cancel, resume, or fix payment —
all folding from the same /api/tiers rows + subscribe/change/cancel/portal
endpoints the web uses.

- Models: TierRow gains the paid-access fields (price_cents/currency/interval
  + this member's status/paid_until/grace_deadline) and display helpers
  (priceText, pricePer, sellable, subscribed); now Identifiable.
- LChatAPI: subscribeTier/changeTier/cancelTierSub/tierPortal, returning a
  BillingResult (.url to open hosted checkout/portal, .ok, .failed) — strict
  on status so a 4xx never reads as success.
- ThreadStore: membershipTiers() + the four billing actions (refresh on the
  in-place change/cancel so a new level key can be picked up).
- MembershipView (LChatCore, shared): calm, dated, never-a-dark-pattern —
  every ending state names the exact day access runs to, cancel is framed
  as "you keep it until then", state is icon+words (never colour alone),
  one accent per card. Checkout/portal open in the browser; entitlement
  arrives via the webhook, so pull-to-refresh shows the change.
- Wired into iOS ServerSettingsSheet and Mac MacSettingsView, gated to
  spaces that actually sell (or where the member already holds) a level —
  no dead entry otherwise.

Native admin level management (create/price/link Stripe) stays web-only for
now — the native app tracks no admin role; deferred, noted in the design doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: note native Membership surface + admin-management deferral

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web+native: fix 10 confirmed billing-review bugs

A high-effort adversarial review of the Stripe Connect subscription layer
found a cluster of real money/lifecycle defects. All fixed (feature is still
dormant until Stripe env + a linked account):

1. Cross-account webhook forgery — a Connect endpoint receives EVERY connected
   account's events, all signed with the one platform secret, so a valid
   signature isn't proof the event is ours. ParseWebhook now binds each event to
   THIS space's connected account and drops the rest.
2. Resume/subscribe double-charge — "Resume" (and any repeat subscribe) opened a
   fresh Checkout, creating a second live subscription. Added a Resume path that
   un-cancels the SAME sub (Stripe cancel_at_period_end=false); subscribe now
   409s when the member already has one. Web + native "Resume" call it.
3. Revoke on a superseded sub — a late delete for a replaced subscription kicked
   a member who'd re-subscribed. The webhook now ignores events for a sub that
   isn't the member's current one (and never lets one overwrite the record).
4. setEntitlement clobbered admin/invite grants — a paid sub flipped source to
   'stripe', so its lapse stripped an admin comp. UPDATE no longer touches
   source; revoke is source-aware (revokeEntitlement) — a lapse keeps an
   admin/invite grant + key, only clearing the subscription tracking.
5. Idempotency/ordering — Stripe is at-least-once and can reorder. Added an
   event-`created` watermark (ext_event_ts) that drops stale events, and made
   only a genuine new-subscription signal (checkout / subscription.created) able
   to CREATE a membership — a redelivered "active" can't resurrect a revoked one.
6. Swallowed error under HTTP 200 — a transient DB failure acked the event so
   Stripe never retried (member charged, not activated). applyWebhookEvent now
   returns the error and the webhook responds 500 so Stripe retries.
7. Missing userExists guard — a webhook naming a removed/deleted member created
   a ghost membership. Now guards userExists like the admin assign path.
8. Zero-decimal currency 100× overcharge — any 3-letter code passed; JPY cents
   were sent as whole yen. Prices are gated to a 2-decimal allowlist.
9. PaidUntil zeroed by an out-of-order checkout event (no period end) — the
   active branch now keeps the stored renewal date when the event carries none.
10. Grace sweep missed silent lapses — a lapse whose webhook never arrived left
    grace_deadline 0 forever. The sweep now also revokes subs whose paid period
    ended more than the grace window ago (the true backstop).

Also removed dead strMinorUnits and simplified a redundant origin() condition.
New tests cover no-resurrect, superseded-sub, admin-grant preservation, the
paid-until guard, the silent-lapse backstop, duplicate-subscribe, resume, and
the currency allowlist. go test ./... + npm test + LChatCore/Mac builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell eb840c4fae95e7892abb6e8457b59ec97a20b9ad parent 08a8777
16 files changed, +2671 −3
  • changelog/2026-07-11-paid-access-levels.md +6 −0
  • db.go +204 −2
  • docs/community-and-tiers.md +17 −0
  • ios/LChat/Views/ServerSettingsSheet.swift +18 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +55 −0
  • ios/LChatCore/Sources/LChatCore/MembershipView.swift +352 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +26 −1
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +48 −0
  • ios/LChatMac/MacSettingsView.swift +32 −0
  • main.go +21 −0
  • subscriptions.go +502 −0
  • subscriptions_handlers.go +446 −0
  • subscriptions_test.go +389 −0
  • web/static/apps/admin.js +174 −0
  • web/static/shell.js +358 −0
  • web/static/style.css +23 −0
diff --git a/changelog/2026-07-11-paid-access-levels.md b/changelog/2026-07-11-paid-access-levels.md
new file mode 100644
index 0000000..d4e9de4
--- /dev/null
+++ b/changelog/2026-07-11-paid-access-levels.md
@@ -0,0 +1,6 @@
+🤖 **Paid access levels.** An admin can now put a price on an access level, and
+members subscribe to it — monthly or yearly — to unlock its groups. Payments
+go straight to the admin's own Stripe account. Members manage everything from
+Membership (in Edit profile, or the ⌘K menu): subscribe, switch level, or
+cancel, always with a clear "your access ends on <date>" so nothing is a
+surprise. Off until an admin links a Stripe account.
diff --git a/db.go b/db.go
index 616df3d..cb7f7c9 100644
--- a/db.go
+++ b/db.go
@@ -630,6 +630,31 @@ CREATE TABLE IF NOT EXISTS member_bots (
// once open_key exists — changing the gate would need a rekey the
// never-overwrite rule forbids (close the group and make a new one).
"ALTER TABLE threads ADD COLUMN open_tier TEXT",
+ // Paid access levels (docs/community-and-tiers.md layer 3): an access
+ // level can be sellable — members subscribe to it. Price is plaintext
+ // commerce config (admin-set, like branding), in minor units (cents).
+ // provider_ref is the payment provider's price id (Stripe price). All
+ // unset → the level is admin/invite-assigned only (no subscribe path).
+ "ALTER TABLE tiers ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE tiers ADD COLUMN currency TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE tiers ADD COLUMN interval TEXT NOT NULL DEFAULT ''", // 'month' | 'year'
+ "ALTER TABLE tiers ADD COLUMN provider_ref TEXT",
+ // Subscription entitlement on a membership (source='stripe'): its
+ // lifecycle. status '' for admin/invite assignments; 'active' |
+ // 'past_due' | 'canceled' for paid ones. paid_until is the paid-through
+ // time (the current period end). grace_deadline is when access is
+ // finally revoked after lapse/cancel (paid_until + the grace window);
+ // 0 = not lapsing. ext_sub/ext_customer are the provider's ids, so a
+ // webhook can find the membership again.
+ "ALTER TABLE user_tiers ADD COLUMN status TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE user_tiers ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE user_tiers ADD COLUMN grace_deadline INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE user_tiers ADD COLUMN ext_sub TEXT",
+ "ALTER TABLE user_tiers ADD COLUMN ext_customer TEXT",
+ // The provider event `created` time last folded for this subscription —
+ // so a redelivered or out-of-order webhook (Stripe is at-least-once and
+ // can reorder) is ignored rather than resurrecting a revoked membership.
+ "ALTER TABLE user_tiers ADD COLUMN ext_event_ts INTEGER NOT NULL DEFAULT 0",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -1741,6 +1766,18 @@ type Tier struct {
// Hidden: MY membership of this level is currently hidden from non-members
// — meaningful only when I'm a member and Visibility=='user'.
Hidden bool `json:"hidden,omitempty"`
+ // Paid access (layer 3): when PriceCents>0 the level is sellable — members
+ // can subscribe. Plaintext commerce config, like branding.
+ PriceCents int `json:"price_cents,omitempty"`
+ Currency string `json:"currency,omitempty"`
+ Interval string `json:"interval,omitempty"` // 'month' | 'year'
+ ProviderRef string `json:"-"` // provider price id; never sent to clients
+ // The requesting user's subscription on this level (source='stripe'),
+ // filled by listTiers for the member's own view: status + when access
+ // runs out.
+ Status string `json:"status,omitempty"`
+ PaidUntil int64 `json:"paid_until,omitempty"`
+ GraceDeadline int64 `json:"grace_deadline,omitempty"`
}
func (db *DB) createTier(name string) (string, error) {
@@ -1826,6 +1863,168 @@ func (db *DB) unassignTier(tierID, userID string) error {
return tx.Commit()
}
+// setTierPrice makes a level sellable (or, with cents=0, not). Plaintext
+// commerce config; provider_ref is the payment provider's price id.
+func (db *DB) setTierPrice(tierID string, cents int, currency, interval, providerRef string) error {
+ _, err := db.sql.Exec(
+ "UPDATE tiers SET price_cents = ?, currency = ?, interval = ?, provider_ref = ? WHERE id = ?",
+ cents, currency, interval, nullable(providerRef), tierID)
+ return err
+}
+
+// tierPrice returns a level's price config (for building a checkout).
+func (db *DB) tierPrice(tierID string) (cents int, currency, interval, providerRef string, ok bool) {
+ var pr sql.NullString
+ err := db.sql.QueryRow(
+ "SELECT COALESCE(price_cents,0), COALESCE(currency,''), COALESCE(interval,''), provider_ref FROM tiers WHERE id = ?",
+ tierID).Scan(&cents, &currency, &interval, &pr)
+ if err != nil {
+ return 0, "", "", "", false
+ }
+ return cents, currency, interval, pr.String, cents > 0
+}
+
+// Entitlement is a member's paid membership of a level (source='stripe').
+type Entitlement struct {
+ TierID string
+ UserID string
+ Status string // active | past_due | canceled
+ PaidUntil int64
+ GraceDeadline int64
+ ExtSub string
+ ExtCustomer string
+ EventTS int64 // provider event `created` last folded, for ordering
+ Source string // how the membership was assigned: admin | invite | stripe
+}
+
+// setEntitlement upserts a member's paid membership. It ensures the user_tiers
+// row exists, then stamps the subscription lifecycle — so a webhook can
+// create-or-update in one call. It NEVER changes an existing row's source: a
+// paid subscription tracks its lifecycle in the ext_* columns, but an
+// admin/invite grant underneath stays sticky (so a lapse can't strip an
+// admin's permanent comp — the revoke path checks source). Assigning the level
+// to the member (key delivery) is the caller's job on activation, via
+// assignTier + a tier-key prod, exactly like an admin assignment.
+func (db *DB) setEntitlement(e Entitlement) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(
+ "INSERT INTO user_tiers(tier_id, user_id, source, created_at) VALUES(?, ?, 'stripe', ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
+ e.TierID, e.UserID, time.Now().Unix()); err != nil {
+ return err
+ }
+ // No source= here: preserve an existing admin/invite grant (see doc above).
+ if _, err := tx.Exec(
+ "UPDATE user_tiers SET status=?, paid_until=?, grace_deadline=?, ext_sub=?, ext_customer=?, ext_event_ts=? WHERE tier_id=? AND user_id=?",
+ e.Status, e.PaidUntil, e.GraceDeadline, nullable(e.ExtSub), nullable(e.ExtCustomer), e.EventTS, e.TierID, e.UserID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// clearEntitlement drops only the subscription tracking (status + provider ids)
+// from a membership, KEEPING the row, its source, and the tier key. Used when a
+// stripe subscription lapses on a membership whose sticky source is admin/invite
+// — the paid lifecycle ends, but the admin-granted access must not.
+func (db *DB) clearEntitlement(tierID, userID string) error {
+ _, err := db.sql.Exec(
+ "UPDATE user_tiers SET status='', paid_until=0, grace_deadline=0, ext_sub=NULL, ext_customer=NULL, ext_event_ts=0 WHERE tier_id=? AND user_id=?",
+ tierID, userID)
+ return err
+}
+
+// revokeEntitlement ends a lapsed/cancelled subscription. If the membership
+// exists ONLY because of the subscription (source='stripe'), it fully unassigns
+// the tier (key + membership); if an admin/invite grant sits underneath, it
+// keeps that access and just clears the subscription tracking. removed reports
+// whether access was actually taken away.
+func (db *DB) revokeEntitlement(tierID, userID string) (removed bool, err error) {
+ src, _ := db.tierSource(tierID, userID)
+ if src == "stripe" {
+ return true, db.unassignTier(tierID, userID)
+ }
+ return false, db.clearEntitlement(tierID, userID)
+}
+
+// tierSource reads how a membership was assigned (admin | invite | stripe).
+func (db *DB) tierSource(tierID, userID string) (string, bool) {
+ var src sql.NullString
+ err := db.sql.QueryRow(
+ "SELECT source FROM user_tiers WHERE tier_id=? AND user_id=?", tierID, userID).Scan(&src)
+ if err != nil {
+ return "", false
+ }
+ return src.String, true
+}
+
+// entitlementBySub finds the paid membership a provider subscription id maps to
+// — how a webhook (which knows only the subscription) locates the member.
+func (db *DB) entitlementBySub(extSub string) (Entitlement, bool) {
+ var e Entitlement
+ var status sql.NullString
+ err := db.sql.QueryRow(
+ "SELECT tier_id, user_id, COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), COALESCE(ext_event_ts,0) FROM user_tiers WHERE ext_sub = ?",
+ extSub).Scan(&e.TierID, &e.UserID, &status, &e.PaidUntil, &e.GraceDeadline, &e.EventTS)
+ if err != nil {
+ return Entitlement{}, false
+ }
+ e.ExtSub = extSub
+ e.Status = status.String
+ return e, true
+}
+
+// memberEntitlement returns a member's subscription on one level (for the
+// subscription-management UI). Identified by holding a provider subscription id
+// (any source — an admin-granted member can still self-subscribe), or ok=false.
+func (db *DB) memberEntitlement(tierID, userID string) (Entitlement, bool) {
+ var e Entitlement
+ var status, sub, cust sql.NullString
+ err := db.sql.QueryRow(
+ "SELECT COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), ext_sub, ext_customer, COALESCE(ext_event_ts,0) FROM user_tiers WHERE tier_id=? AND user_id=? AND ext_sub IS NOT NULL AND ext_sub != ''",
+ tierID, userID).Scan(&status, &e.PaidUntil, &e.GraceDeadline, &sub, &cust, &e.EventTS)
+ if err != nil {
+ return Entitlement{}, false
+ }
+ e.TierID, e.UserID = tierID, userID
+ e.Status, e.ExtSub, e.ExtCustomer = status.String, sub.String, cust.String
+ return e, true
+}
+
+// entitlementsDueForRevoke lists subscriptions to revoke: those whose explicit
+// grace deadline has elapsed, AND — the true backstop — active/past-due subs
+// whose paid period ran out more than the grace window ago with no renewal
+// webhook ever arriving (endpoint down, instance hibernated). source rides along
+// so the sweep can tell a pure-stripe membership (fully unassign) from one with
+// an admin/invite grant underneath (keep access, just drop the sub).
+func (db *DB) entitlementsDueForRevoke(now int64) ([]Entitlement, error) {
+ graceSecs := int64(billingGrace.Seconds())
+ rows, err := db.sql.Query(
+ `SELECT tier_id, user_id, source FROM user_tiers
+ WHERE ext_sub IS NOT NULL AND ext_sub != '' AND (
+ (grace_deadline > 0 AND grace_deadline <= ?)
+ OR (paid_until > 0 AND paid_until + ? <= ?)
+ )`,
+ now, graceSecs, now)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Entitlement{}
+ for rows.Next() {
+ var e Entitlement
+ var src sql.NullString
+ if err := rows.Scan(&e.TierID, &e.UserID, &src); err != nil {
+ return nil, err
+ }
+ e.Source = src.String
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
// grantTierKey stores one member's wrapped copy of a tier key. DO NOTHING,
// never overwrite — the grantInstanceKey anti-brick rule, per tier.
func (db *DB) grantTierKey(tierID, userID, wrapped string) error {
@@ -1963,7 +2162,9 @@ func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
SELECT t.id, t.name, t.created_at, COALESCE(k.wrapped, ''), COALESCE(t.key_minted, 0),
COALESCE(t.visibility, 'shown'),
CASE WHEN m.user_id IS NULL THEN 0 ELSE 1 END,
- COALESCE(m.visible, 1)
+ COALESCE(m.visible, 1),
+ COALESCE(t.price_cents, 0), COALESCE(t.currency, ''), COALESCE(t.interval, ''),
+ COALESCE(m.status, ''), COALESCE(m.paid_until, 0), COALESCE(m.grace_deadline, 0)
FROM tiers t
LEFT JOIN tier_keys k ON k.tier_id = t.id AND k.user_id = ?
LEFT JOIN user_tiers m ON m.tier_id = t.id AND m.user_id = ?
@@ -1978,7 +2179,8 @@ func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
for rows.Next() {
var t Tier
var m, mine, vis int
- if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Visibility, &mine, &vis); err != nil {
+ if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Visibility, &mine, &vis,
+ &t.PriceCents, &t.Currency, &t.Interval, &t.Status, &t.PaidUntil, &t.GraceDeadline); err != nil {
return nil, err
}
t.Mine = mine == 1
diff --git a/docs/community-and-tiers.md b/docs/community-and-tiers.md
index c77f3a1..bec7388 100644
--- a/docs/community-and-tiers.md
+++ b/docs/community-and-tiers.md
@@ -202,6 +202,23 @@ the hub; a self-hosted instance simply doesn't offer it.
## Build order
+> **Status (2026-07): 1–5 SHIPPED.** Open groups, default groups, tiers
+> ("access levels"), level-gated groups, cohort invites, and the visibility
+> policy are on main and in production. **Stripe Connect member subscriptions
+> (5) are built** (`subscriptions.go`): the entitlement engine on `user_tiers`,
+> the `PayProvider` seam + `stripeConnect` (10% application fee, hand-rolled
+> like `billing.go`), member subscribe/change/cancel/portal + the Membership
+> UI, admin price + account linking, the webhook, and the 30-day grace→revoke
+> sweep. **Dormant** until `LCHAT_STRIPE_CONNECT_KEY` + `_WEBHOOK_SECRET` are
+> set and a space links a connected account. The **member** Membership surface
+> ships on all three clients — web (`showMembership` in shell.js) and native
+> (`MembershipView` in `LChatCore`, wired into iOS Settings + the Mac Settings
+> window). Deferred: moving the Stripe platform secret onto the *broker* (the
+> hub is instance-env for now — a provider swap), the Stripe-hosted Connect
+> *onboarding link* (the admin pastes their `acct_…` today), **native admin
+> level management** (create/price/link — web-only for now; the native app
+> tracks no admin role), and (6) app-store IAP.
+
1. **This doc** — pin the decisions.
2. **Open groups** — `visibility` flag, instance-key-wrapped thread key,
directory endpoint + Discover surface, self-join. iOS + Mac + web
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift
index eb5094b..de2b502 100644
--- a/ios/LChat/Views/ServerSettingsSheet.swift
+++ b/ios/LChat/Views/ServerSettingsSheet.swift
@@ -17,6 +17,7 @@ struct ServerSettingsSheet: View {
@State private var savedAway = false // last server-confirmed value — makes onChange idempotent (initial load, failure revert)
@State private var awayLoaded = false // gate the toggle until /api/me answers
@State private var awayNote: String?
+ @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level
var body: some View {
NavigationStack {
@@ -38,6 +39,17 @@ struct ServerSettingsSheet: View {
} footer: {
Text("Your dot stays yellow until you switch this off — being active won't flip you back to online.")
}
+ if hasPaidLevels {
+ Section {
+ NavigationLink {
+ MembershipView(store: store)
+ } label: {
+ Label("Membership", systemImage: "creditcard")
+ }
+ } footer: {
+ Text("Subscribe to a paid access level, or change and cancel your plan.")
+ }
+ }
Section("Theme") {
Picker("Theme", selection: $themeName) {
ForEach(ChatTheme.presets, id: \.id) { preset in
@@ -78,6 +90,12 @@ struct ServerSettingsSheet: View {
awayNote = "Couldn't load your status."
}
}
+ .task {
+ // Only surface Membership where paid access levels exist (or the
+ // member already has a subscription) — no dead entry otherwise.
+ let tiers = await store.membershipTiers()
+ hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed }
+ }
}
private func setAway(_ next: Bool) {
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index eb27477..2836b35 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -51,6 +51,61 @@ public struct LChatAPI {
_ = await postJSON("/api/tiers/\(tier)/nudge", ["token": token])
}
+ // MARK: Paid access levels (member side, subscriptions.go)
+
+ /// A billing action's outcome: a hosted URL to send the member to (Stripe
+ /// checkout or the billing portal), a plain success (change/cancel), or a
+ /// failure. Mirrors the web's billingPost, which follows `url` when present.
+ public enum BillingResult { case url(URL), ok, failed }
+
+ /// Start checkout for a sellable level. On success returns the hosted
+ /// payment URL; entitlement is granted by the webhook once payment clears.
+ public func subscribeTier(_ id: String) async -> BillingResult {
+ await billingPost("/api/tiers/\(id)/subscribe", ["token": token])
+ }
+
+ /// Switch an existing subscription to another sellable level (Stripe
+ /// prorates). Returns .ok / .failed — no redirect.
+ public func changeTier(from: String, to: String) async -> BillingResult {
+ await billingPost("/api/tiers/\(from)/change", ["token": token, "to_tier": to])
+ }
+
+ /// Cancel at period end — access continues until the paid period runs out.
+ public func cancelTierSub(_ id: String) async -> BillingResult {
+ await billingPost("/api/tiers/\(id)/cancel-subscription", ["token": token])
+ }
+
+ /// Resume an ending subscription: un-cancels the SAME subscription (no new
+ /// checkout, no new charge). Returns .ok / .failed.
+ public func resumeTier(_ id: String) async -> BillingResult {
+ await billingPost("/api/tiers/\(id)/resume", ["token": token])
+ }
+
+ /// Open the Stripe billing portal (card / invoices / cancellation). Returns
+ /// the portal URL to send the member to.
+ public func tierPortal(_ id: String) async -> BillingResult {
+ await billingPost("/api/tiers/\(id)/portal", ["token": token])
+ }
+
+ /// POST a billing action, checking the status and following a `{url}` body.
+ /// Strict about status (unlike postJSON) so a 4xx never reads as success.
+ private func billingPost(_ path: String, _ body: [String: Any]) async -> BillingResult {
+ guard let url = URL(string: path, relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return .failed }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (respBody, resp) = try? await session.data(for: req),
+ (resp as? HTTPURLResponse)?.statusCode == 200 else { return .failed }
+ struct R: Codable { let url: String? }
+ if let s = (try? JSONDecoder().decode(R.self, from: respBody))?.url,
+ !s.isEmpty, let u = URL(string: s) {
+ return .url(u)
+ }
+ return .ok
+ }
+
/// Self-serve join an open group. True only on a real 200 — a 403 (the
/// group closed while the sheet was up) must not read as joined.
public func joinThread(_ thread: String) async -> Bool {
diff --git a/ios/LChatCore/Sources/LChatCore/MembershipView.swift b/ios/LChatCore/Sources/LChatCore/MembershipView.swift
new file mode 100644
index 0000000..70efbc8
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/MembershipView.swift
@@ -0,0 +1,352 @@
+import SwiftUI
+
+// 🤖 The member's paid-access surface — the native twin of showMembership in
+// web/static/shell.js. It lists the access levels a member can subscribe to,
+// shows their current subscription and its exact state, and lets them change,
+// cancel, or fix payment. Shared by iOS and Mac so the two can't drift.
+//
+// Design stance (matches the web): calm and never a dark pattern. The signature
+// is the DATED PROMISE — every ending/lapsing state names the exact day access
+// runs to ("Your access ends on 5 February 2026"), and cancelling is framed as
+// "you keep it until then," not "you lose it now." State is carried by an icon
+// AND words, never colour alone. One accent (the brand tint) marks the single
+// loud action per card; everything else is quiet.
+//
+// Checkout and the billing portal are hosted by Stripe: those actions return a
+// URL we open in the browser. Payment completes there; the entitlement (and the
+// level key) arrive back via the webhook, so the member pulls to refresh — or
+// reopens this sheet — to see the change.
+public struct MembershipView: View {
+ @ObservedObject var store: ThreadStore
+ @Environment(\.openURL) private var openURL
+
+ private enum Phase {
+ case loading
+ case loaded([TierRow])
+ }
+ @State private var phase: Phase = .loading
+ @State private var working: String? // tier id mid-action (disables buttons)
+ @State private var status: String? // inline status line (never a toast)
+ @State private var cancelTarget: TierRow? // drives the dated cancel confirmation
+ @State private var changeFrom: TierRow? // drives the change-level sheet
+
+ public init(store: ThreadStore) { self.store = store }
+
+ public var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ switch phase {
+ case .loading:
+ HStack { Spacer(); ProgressView(); Spacer() }.padding(.top, 40)
+ case .loaded(let tiers):
+ content(tiers)
+ }
+ }
+ .padding()
+ .frame(maxWidth: 560, alignment: .leading)
+ .frame(maxWidth: .infinity)
+ }
+ .navigationTitle("Membership")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .task { await load() }
+ .refreshable { await load() }
+ .confirmationDialog(cancelPrompt, isPresented: cancelPresented, titleVisibility: .visible) {
+ if let t = cancelTarget {
+ Button("Cancel subscription", role: .destructive) { cancel(t) }
+ }
+ Button("Keep it", role: .cancel) {}
+ }
+ .sheet(item: $changeFrom) { from in
+ ChangeLevelSheet(from: from, others: sellable(currentTiers).filter { $0.id != from.id }) { to in
+ changeFrom = nil
+ change(from: from, to: to)
+ }
+ }
+ }
+
+ // MARK: Content
+
+ @ViewBuilder private func content(_ tiers: [TierRow]) -> some View {
+ let sellableTiers = sellable(tiers)
+ let subscribed = tiers.filter(\.subscribed)
+ let canSubscribe = sellableTiers.filter { !$0.subscribed }
+
+ if let status {
+ Text(status)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .accessibilityAddTraits(.updatesFrequently)
+ }
+
+ if sellableTiers.isEmpty && subscribed.isEmpty {
+ Text("This space doesn't offer paid access levels.")
+ .foregroundStyle(.secondary)
+ .padding(.top, 24)
+ } else {
+ Text("Subscriptions are handled securely by Stripe. You can change or cancel anytime.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ // Your subscription(s) — current state, dated.
+ ForEach(subscribed) { planCard($0, isCurrent: true, others: sellableTiers) }
+
+ // Levels available to subscribe to (not already subscribed).
+ if !canSubscribe.isEmpty {
+ if !subscribed.isEmpty {
+ Text("Other access levels")
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ .padding(.top, 4)
+ }
+ ForEach(canSubscribe) { planCard($0, isCurrent: false, others: sellableTiers) }
+ }
+ }
+ }
+
+ // A single access level as a card: name + price, a dated state line for a
+ // current subscription, and the right actions for that state.
+ @ViewBuilder private func planCard(_ t: TierRow, isCurrent: Bool, others: [TierRow]) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ if isCurrent {
+ Text("Your plan")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(Color.accentColor)
+ .textCase(.uppercase)
+ .accessibilityHidden(true)
+ }
+ HStack(alignment: .firstTextBaseline) {
+ Text(t.name ?? "Access level")
+ .font(.headline)
+ Spacer(minLength: 8)
+ if let price = t.priceText {
+ (Text(price).font(.headline)
+ + Text(" / \(t.pricePer)").font(.subheadline).foregroundColor(.secondary))
+ }
+ }
+ if isCurrent { stateLine(t) }
+ actions(t, others: others)
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(Color.secondaryBackground)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(isCurrent ? Color.accentColor.opacity(0.35) : Color.clear, lineWidth: 1)
+ )
+ }
+
+ // The dated status sentence — icon (shape) AND words, never colour alone.
+ @ViewBuilder private func stateLine(_ t: TierRow) -> some View {
+ let status = t.status ?? ""
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ switch status {
+ case "active":
+ Image(systemName: "checkmark.seal.fill").foregroundStyle(.secondary)
+ Text(t.paid_until != nil ? "Renews on \(dateStr(t.paid_until))" : "Active")
+ case "past_due":
+ Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
+ Text("Payment didn't go through — access ends \(dateStr(t.grace_deadline)) unless it's fixed.")
+ default: // canceled / ending
+ Image(systemName: "calendar").foregroundStyle(.secondary)
+ Text("Your access ends on \(dateStr(t.grace_deadline ?? t.paid_until)).")
+ }
+ }
+ .font(.subheadline)
+ .accessibilityElement(children: .combine)
+ }
+
+ // The action buttons appropriate to a level's state. Exactly one loud
+ // (accent) action per card; the rest are quiet.
+ @ViewBuilder private func actions(_ t: TierRow, others: [TierRow]) -> some View {
+ let busy = working != nil
+ let status = t.status ?? ""
+ HStack(spacing: 10) {
+ if !t.subscribed {
+ loud("Subscribe", busy: busy) { act(t.id) { await store.subscribeTier(t.id) } }
+ } else if status == "active" {
+ if others.contains(where: { $0.id != t.id }) {
+ quiet("Change level", busy: busy) { changeFrom = t }
+ }
+ quiet("Cancel", busy: busy) { cancelTarget = t }
+ quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } }
+ } else if status == "past_due" {
+ loud("Update payment", busy: busy) { act(t.id) { await store.tierPortal(t.id) } }
+ quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } }
+ } else { // ending — offer to resume (un-cancels the same sub, no new charge)
+ loud("Resume", busy: busy) { act(t.id) { await store.resumeTier(t.id) } }
+ quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } }
+ }
+ Spacer(minLength: 0)
+ if working == t.id { ProgressView().controlSize(.small) }
+ }
+ .padding(.top, 2)
+ }
+
+ private func loud(_ label: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
+ Button(label, action: tap)
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ .controlSize(.small)
+ .disabled(busy)
+ }
+
+ private func quiet(_ label: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
+ Button(label, action: tap)
+ .buttonStyle(.bordered)
+ .buttonBorderShape(.capsule)
+ .controlSize(.small)
+ .disabled(busy)
+ }
+
+ // MARK: State + actions
+
+ private var currentTiers: [TierRow] {
+ if case .loaded(let t) = phase { return t }
+ return []
+ }
+
+ private func sellable(_ tiers: [TierRow]) -> [TierRow] { tiers.filter(\.sellable) }
+
+ private var cancelPresented: Binding<Bool> {
+ Binding(get: { cancelTarget != nil }, set: { if !$0 { cancelTarget = nil } })
+ }
+
+ // Dated promise, not a threat: name the exact day access continues to.
+ private var cancelPrompt: String {
+ guard let t = cancelTarget else { return "Cancel your subscription?" }
+ let until = dateStr(t.paid_until)
+ let name = t.name ?? "this level"
+ return until.isEmpty
+ ? "Cancel your \(name) subscription?"
+ : "You'll keep \(name) until \(until). Cancel your subscription?"
+ }
+
+ private func load() async {
+ let tiers = await store.membershipTiers()
+ phase = .loaded(tiers)
+ }
+
+ // Run a billing action, following a hosted URL when one comes back.
+ private func act(_ id: String, _ fn: @escaping () async -> LChatAPI.BillingResult) {
+ working = id
+ status = nil
+ Task {
+ let result = await fn()
+ working = nil
+ switch result {
+ case .url(let u):
+ openURL(u)
+ status = "Continue in your browser, then pull to refresh here."
+ case .ok:
+ status = "Done."
+ await load()
+ case .failed:
+ status = "Something went wrong — try again."
+ }
+ }
+ }
+
+ private func cancel(_ t: TierRow) {
+ working = t.id
+ status = "Cancelling…"
+ Task {
+ let result = await store.cancelTierSub(t.id)
+ working = nil
+ if case .ok = result {
+ status = "Cancelled — you keep access until it ends."
+ await load()
+ } else {
+ status = "Couldn't cancel — try again."
+ }
+ }
+ }
+
+ private func change(from: TierRow, to: TierRow) {
+ working = from.id
+ status = "Switching…"
+ Task {
+ let result = await store.changeTier(from: from.id, to: to.id)
+ working = nil
+ if case .ok = result {
+ status = "Switched."
+ await load()
+ } else {
+ status = "Couldn't switch — try again."
+ }
+ }
+ }
+
+ private func dateStr(_ unix: Int64?) -> String {
+ guard let u = unix, u > 0 else { return "" }
+ return Date(timeIntervalSince1970: Double(u))
+ .formatted(.dateTime.day().month(.wide).year())
+ }
+}
+
+// Change to another sellable level — a small picker sheet. Switching adjusts the
+// next bill for the difference (Stripe prorates); confirmed on the parent.
+private struct ChangeLevelSheet: View {
+ let from: TierRow
+ let others: [TierRow]
+ let onPick: (TierRow) -> Void
+ @Environment(\.dismiss) private var dismiss
+ @State private var confirming: TierRow?
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ ForEach(others, id: \.id) { t in
+ Button { confirming = t } label: {
+ HStack {
+ Text(t.name ?? "Access level").foregroundStyle(.primary)
+ Spacer()
+ if let price = t.priceText {
+ Text("\(price) / \(t.pricePer)").foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ } footer: {
+ Text("You're on \(from.name ?? "this level"). Switching adjusts your next bill for the difference.")
+ }
+ }
+ .navigationTitle("Change level")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ .confirmationDialog(
+ confirming.map { "Switch to \($0.name ?? "this level")\($0.priceText.map { " (\($0) / \(confirming!.pricePer))" } ?? "")?" } ?? "",
+ isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
+ titleVisibility: .visible
+ ) {
+ if let t = confirming {
+ Button("Switch") { onPick(t) }
+ }
+ Button("Keep current", role: .cancel) {}
+ }
+ }
+ }
+}
+
+// A cross-platform "grouped card" fill — the native equivalent of the web's
+// .plan-card background, adapting to light/dark.
+extension Color {
+ static var secondaryBackground: Color {
+ #if os(iOS)
+ return Color(uiColor: .secondarySystemBackground)
+ #else
+ return Color(nsColor: .underPageBackgroundColor)
+ #endif
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 013cbc3..fd32d66 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -231,13 +231,38 @@ public struct OpenThreadRow: Codable {
// One access level from GET /api/tiers, personalised to this user (mirrors
// the web's Tier rows; names are plaintext space policy, like branding).
-public struct TierRow: Codable {
+public struct TierRow: Codable, Identifiable {
public let id: String
public var name: String?
public var wrapped: String? // our copy of the level key (ECIES), if granted
public var mine: Bool?
public var members: [String]?
public var have: [String]?
+ // Paid access (layer 3), same fields the web reads off a Tier. price_cents>0
+ // means the level is sellable; status/paid_until/grace_deadline describe THIS
+ // member's own subscription (source='stripe'). Plaintext commerce config.
+ public var price_cents: Int?
+ public var currency: String?
+ public var interval: String? // 'month' | 'year'
+ public var status: String? // '' | 'active' | 'past_due' | 'canceled'
+ public var paid_until: Int64?
+ public var grace_deadline: Int64?
+
+ /// A sellable level — a member can subscribe to it.
+ public var sellable: Bool { (price_cents ?? 0) > 0 }
+ /// The requesting member has (or recently had) a subscription here.
+ public var subscribed: Bool { !(status ?? "").isEmpty }
+ /// Price as "€5" (symbol-lite: falls back to the code when unknown), or nil.
+ public var priceText: String? {
+ guard let cents = price_cents, cents > 0 else { return nil }
+ let amount = Double(cents) / 100
+ let str = cents % 100 == 0 ? String(Int(amount)) : String(format: "%.2f", amount)
+ let sym = ["usd": "$", "eur": "€", "gbp": "£"][(currency ?? "").lowercased()]
+ ?? ((currency ?? "").uppercased() + " ")
+ return sym + str
+ }
+ /// The billing period word for the price ("month" / "year").
+ public var pricePer: String { interval == "year" ? "year" : "month" }
}
// A decrypted directory row, ready for the Discover list on either platform.
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 4ad69d1..be99ecb 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -772,6 +772,54 @@ public final class ThreadStore: ObservableObject {
return true
}
+ // MARK: Paid access levels (member side)
+
+ /// Fetch the access levels fresh (price + this member's subscription state)
+ /// for the Membership view — bypasses the cached `tiers` snapshot so the
+ /// sheet always shows current status after a change.
+ public func membershipTiers() async -> [TierRow] {
+ guard let api else { return [] }
+ return await api.tiers()
+ }
+
+ /// Start checkout for a sellable level. .url → send the member to the
+ /// hosted page; entitlement (and the level key) arrive via the webhook.
+ public func subscribeTier(_ id: String) async -> LChatAPI.BillingResult {
+ guard let api else { return .failed }
+ return await api.subscribeTier(id)
+ }
+
+ /// Switch a subscription to another level in place (prorated). Refreshes on
+ /// success so the new level's key can be picked up.
+ public func changeTier(from: String, to: String) async -> LChatAPI.BillingResult {
+ guard let api else { return .failed }
+ let r = await api.changeTier(from: from, to: to)
+ if case .ok = r { await refresh() }
+ return r
+ }
+
+ /// Cancel at period end — access stays until the paid period runs out.
+ public func cancelTierSub(_ id: String) async -> LChatAPI.BillingResult {
+ guard let api else { return .failed }
+ let r = await api.cancelTierSub(id)
+ if case .ok = r { await refresh() }
+ return r
+ }
+
+ /// Resume an ending subscription (un-cancel the same sub, no new charge).
+ public func resumeTier(_ id: String) async -> LChatAPI.BillingResult {
+ guard let api else { return .failed }
+ let r = await api.resumeTier(id)
+ if case .ok = r { await refresh() }
+ return r
+ }
+
+ /// Open the Stripe billing portal for this level's subscription.
+ public func tierPortal(_ id: String) async -> LChatAPI.BillingResult {
+ guard let api else { return .failed }
+ return await api.tierPortal(id)
+ }
+
// (including us) via ECIES, encrypts the group name, and creates the thread; the
// server dedupes DMs. Returns the thread id (refreshing the list), or nil on error.
// `group` = the caller's explicit intent (the group flow was used) — the
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index 6107434..a8683c2 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -18,6 +18,8 @@ struct MacSettingsView: View {
@State private var savedAway = false // last server-confirmed value — makes onChange idempotent (load, revert)
@State private var awayLoaded = false // gate the toggle until /api/me answers
@State private var awayNote: String?
+ @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level
+ @State private var showMembership = false
private var instance: SavedInstance? {
app.instances.first { $0.id == selectedInstanceID } ?? app.instances.first
@@ -86,19 +88,49 @@ struct MacSettingsView: View {
Text(awayNote).font(.footnote).foregroundStyle(.secondary)
}
}
+ if hasPaidLevels {
+ Section("Membership") {
+ Button("Manage membership…") { showMembership = true }
+ Text("Subscribe to a paid access level, or change and cancel your plan.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
+ }
}
.formStyle(.grouped)
+ .sheet(isPresented: $showMembership) {
+ if let inst = instance {
+ NavigationStack { MembershipView(store: ThreadStore(instance: inst)) }
+ .frame(minWidth: 460, minHeight: 460)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { showMembership = false }
+ }
+ }
+ }
+ }
// No frame here — the Settings window's single fixed size lives on the
// TabView (LChatMacApp), so every tab shares it.
.onAppear {
selectedInstanceID = instance?.id
name = instance?.screenName ?? ""
loadAway()
+ loadPaidLevels()
}
.onChange(of: selectedInstanceID) { _, _ in
name = instance?.screenName ?? ""
note = nil
loadAway()
+ loadPaidLevels()
+ }
+ }
+
+ // Only surface Membership where paid access levels exist (or I'm already
+ // subscribed) — no dead entry on spaces that don't sell access.
+ private func loadPaidLevels() {
+ hasPaidLevels = false
+ Task {
+ let tiers = await api?.tiers() ?? []
+ hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed }
}
}
diff --git a/main.go b/main.go
index d4c0e43..5d19e37 100644
--- a/main.go
+++ b/main.go
@@ -103,6 +103,9 @@ type Server struct {
// upd caches the last verified release-manifest fetch, so the admin console
// can say whether the tracked channel has something newer (updatecheck.go).
upd updateState
+ // pay is the payment provider for paid access levels (subscriptions.go).
+ // noPay unless Stripe Connect is configured — the feature is dormant then.
+ pay PayProvider
}
func main() {
@@ -241,6 +244,12 @@ func main() {
}
s.apns = newAPNsClient()
+ // Paid access levels (subscriptions.go): the Stripe Connect provider, or
+ // noPay (dormant) when unconfigured. The grace→revoke sweep only matters
+ // once entitlements exist; harmless to always run.
+ s.pay = newPayProvider(db)
+ s.startBillingSweep()
+
// Mini-app scheduling: deliver due "future messages" (miniapps.go). Tests
// call sweepDueScheduled directly, so only the real server runs the ticker.
go s.runScheduler()
@@ -380,6 +389,18 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/tiers/{id}/nudge", s.nudgeTierKey)
mux.HandleFunc("POST /api/tiers/{id}/policy", s.postTierPolicy)
mux.HandleFunc("POST /api/tiers/{id}/show", s.postTierShow)
+ // Paid access levels (subscriptions.go): admin price + account, member
+ // subscribe/change/cancel/portal, and the provider webhook (public — its
+ // signature is the auth). Dormant when no provider is configured.
+ mux.HandleFunc("POST /api/tiers/{id}/price", s.postTierPrice)
+ mux.HandleFunc("POST /api/tiers/{id}/subscribe", s.postTierSubscribe)
+ mux.HandleFunc("POST /api/tiers/{id}/change", s.postTierChange)
+ mux.HandleFunc("POST /api/tiers/{id}/cancel-subscription", s.postTierCancelSub)
+ mux.HandleFunc("POST /api/tiers/{id}/resume", s.postTierResume)
+ mux.HandleFunc("POST /api/tiers/{id}/portal", s.postTierPortal)
+ mux.HandleFunc("POST /api/admin/billing/account", s.postBillingAccount)
+ mux.HandleFunc("GET /api/admin/billing", s.getBillingStatus)
+ mux.HandleFunc("POST /api/billing/webhook", s.stripeConnectWebhook)
mux.HandleFunc("POST /api/label", s.postLabel)
mux.HandleFunc("POST /api/presence", s.postPresence)
mux.HandleFunc("POST /api/avatar", s.postAvatar)
diff --git a/subscriptions.go b/subscriptions.go
new file mode 100644
index 0000000..372226e
--- /dev/null
+++ b/subscriptions.go
@@ -0,0 +1,502 @@
+package main
+
+// 🤖 Paid access levels — member subscriptions to a space's access levels
+// (docs/community-and-tiers.md layer 3). This is the third *assigner* of a
+// tier, beside admin and invite: a member pays, and the same tier machinery
+// (assignTier + a tier-key grant on activation; unassignTier on revocation)
+// carries them in and out. Nothing here is a decryption key — money is roster
+// metadata (uid ↔ tier ↔ paid-until); the E2EE message content stays opaque.
+//
+// Money flows via Stripe CONNECT to the SPACE ADMIN's connected account (the
+// admin is merchant of record); the platform takes a 10% application fee. The
+// payment backend is a seam (PayProvider); stripeConnect is one implementation.
+// With no provider configured the feature is dormant — a level carries no
+// price, so members see no subscribe path. The entitlement STATE MACHINE
+// (applySubEvent) is provider-independent and unit-tested on its own; providers
+// only ever hand it normalized SubEvents.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var errBillingOff = errors.New("paid access levels are not available on this space")
+
+// The grace window after a subscription lapses (payment fails) before access is
+// finally revoked — the 30 days promised in docs/community-and-tiers.md. A
+// VOLUNTARY cancel gets no bonus grace: access ends exactly at the period the
+// member already paid for. Overridable in tests.
+const defaultBillingGrace = 30 * 24 * time.Hour
+
+var billingGrace = defaultBillingGrace
+
+// The platform commission on member subscriptions (market rate; Substack 10%,
+// Patreon 8–12%). Charged as a Stripe application fee on the admin's account.
+const applicationFeePercent = "10"
+
+// SubEvent is a payment provider's subscription state, normalized. A provider's
+// ParseWebhook produces these; applySubEvent is the only consumer, so adding a
+// provider never touches the state machine.
+type SubEvent struct {
+ ExtSub string // provider subscription id
+ ExtCustomer string // provider customer id
+ PriceRef string // provider price id (which level — resolved to a tier)
+ UserRef string // OUR user id (set as subscription metadata at checkout)
+ TierRef string // OUR tier id (also in metadata — the direct route)
+ Status string // "active" | "past_due" | "canceled"
+ CurrentPeriodEnd int64 // unix seconds the current paid period runs to
+ CancelAtPeriodEnd bool // member asked to cancel; access ends at CurrentPeriodEnd
+ Deleted bool // the subscription is fully gone (revoke now)
+ Created int64 // the provider event's `created` time (event ordering)
+ AllowCreate bool // a genuine "new subscription" signal (checkout completed,
+ // or subscription.created) — the ONLY kind that may CREATE a
+ // membership, so a bare update/delete can't resurrect a revoked one
+}
+
+// CheckoutReq is what a member's subscribe needs from the provider.
+type CheckoutReq struct {
+ TierID string
+ PriceRef string
+ UserRef string // our uid — rides as subscription metadata for the webhook
+ SuccessURL string
+ CancelURL string
+}
+
+// PayProvider is the payment backend seam. The no-op provider (Configured()
+// false) keeps billing dormant until real credentials are wired.
+type PayProvider interface {
+ Configured() bool
+ Checkout(ctx context.Context, req CheckoutReq) (url string, err error)
+ Cancel(ctx context.Context, extSub string) error
+ // Resume un-cancels a subscription still in its paid period (clears
+ // cancel_at_period_end) — the correct "Resume" for an ending membership,
+ // so it never starts a second subscription that double-charges.
+ Resume(ctx context.Context, extSub string) error
+ // SwitchLevel moves an existing subscription to a different level's price
+ // (upgrade/downgrade in place, prorated), re-pointing its metadata so the
+ // next webhook routes to the new level.
+ SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error
+ // CreatePrice mints a recurring Price on the admin's account so setting a
+ // level's price needs no trip to the Stripe dashboard. Returns its id.
+ CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error)
+ PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error)
+ // ParseWebhook verifies a provider callback and normalizes it. nil (no
+ // error) for events we ignore.
+ ParseWebhook(sig string, body []byte) ([]SubEvent, error)
+}
+
+// noPay is the dormant provider — everything reports "not configured".
+type noPay struct{}
+
+func (noPay) Configured() bool { return false }
+func (noPay) Checkout(context.Context, CheckoutReq) (string, error) { return "", errBillingOff }
+func (noPay) Cancel(context.Context, string) error { return errBillingOff }
+func (noPay) Resume(context.Context, string) error { return errBillingOff }
+func (noPay) SwitchLevel(context.Context, string, string, string) error { return errBillingOff }
+func (noPay) CreatePrice(context.Context, string, int, string, string) (string, error) {
+ return "", errBillingOff
+}
+func (noPay) PortalURL(context.Context, string, string) (string, error) { return "", errBillingOff }
+func (noPay) ParseWebhook(string, []byte) ([]SubEvent, error) { return nil, errBillingOff }
+
+// newPayProvider wires the payment backend from env. Tri-state, like
+// newStripeBilling: all-empty → dormant (noPay); a partial config → dormant
+// with a warning (a half-config must never half-work); fully set → Stripe
+// Connect. The connected account itself is per-space (config table), set when
+// the admin links their Stripe account — so even with platform creds present,
+// a space with no connected account still can't sell.
+func newPayProvider(db *DB) PayProvider {
+ key := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_KEY"))
+ wh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET"))
+ if key == "" && wh == "" {
+ return noPay{}
+ }
+ if key == "" || wh == "" {
+ fmt.Fprintln(os.Stderr, "subscriptions: partial Stripe Connect config — set both LCHAT_STRIPE_CONNECT_KEY and LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET (paid access levels stay off)")
+ return noPay{}
+ }
+ return &stripeConnect{
+ db: db,
+ key: key,
+ webhookSecret: wh,
+ apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"),
+ client: &http.Client{Timeout: 20 * time.Second},
+ }
+}
+
+// applySubEvent folds one normalized subscription event into the member's
+// entitlement and returns whether the membership should be ACTIVE (access
+// granted) right now. The caller grants/revokes keys off that. Pure over the
+// clock (now passed in) so it's unit-testable.
+//
+// Rules (docs/community-and-tiers.md § revocation):
+// - active, not cancelling → paid through CurrentPeriodEnd, no end in sight.
+// - active, cancel-at-period-end → keep access until CurrentPeriodEnd, then
+// revoke (a voluntary cancel gets no bonus grace).
+// - past_due (payment failed) → keep access through the 30-day grace from the
+// last paid-until, then revoke.
+// - deleted/canceled outright → revoke on the next sweep.
+func applySubEvent(db *DB, ev SubEvent, tierID, userID string, now int64) (active bool, err error) {
+ e := Entitlement{TierID: tierID, UserID: userID, ExtSub: ev.ExtSub, ExtCustomer: ev.ExtCustomer}
+ switch {
+ case ev.Deleted || ev.Status == "canceled":
+ e.Status = "canceled"
+ e.PaidUntil = ev.CurrentPeriodEnd
+ e.GraceDeadline = now // revoke on the next sweep
+ active = false
+ case ev.Status == "past_due":
+ e.Status = "past_due"
+ paidUntil := ev.CurrentPeriodEnd
+ if paidUntil == 0 {
+ if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 {
+ paidUntil = prev.PaidUntil
+ } else {
+ paidUntil = now
+ }
+ }
+ e.PaidUntil = paidUntil
+ e.GraceDeadline = paidUntil + int64(billingGrace.Seconds())
+ active = now < e.GraceDeadline
+ case ev.CancelAtPeriodEnd:
+ e.Status = "canceled"
+ e.PaidUntil = ev.CurrentPeriodEnd
+ e.GraceDeadline = ev.CurrentPeriodEnd
+ active = now < ev.CurrentPeriodEnd
+ default: // active, healthy
+ e.Status = "active"
+ e.PaidUntil = ev.CurrentPeriodEnd
+ // checkout.session.completed carries no period end (0). If a later-numbered
+ // but earlier-arriving .updated already stored the real renewal date, don't
+ // stomp it back to 0 (the Membership screen would show a blank date).
+ if e.PaidUntil == 0 {
+ if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 {
+ e.PaidUntil = prev.PaidUntil
+ }
+ }
+ e.GraceDeadline = 0
+ active = true
+ }
+ e.EventTS = ev.Created
+ if err := db.setEntitlement(e); err != nil {
+ return false, err
+ }
+ return active, nil
+}
+
+// runBillingSweep revokes paid memberships whose grace window has elapsed —
+// dropping the tier key + membership (unassignTier), the same service-level
+// revocation an admin does by hand. Mirrors miniapps.go's sweep shape: query,
+// per-item action tolerant of errors, returns a count so tests call it
+// directly. No-op when nothing is due.
+func (s *Server) runBillingSweep(now int64) int {
+ due, err := s.db.entitlementsDueForRevoke(now)
+ if err != nil {
+ return 0
+ }
+ n := 0
+ for _, e := range due {
+ // revokeEntitlement drops the tier key + membership for a pure-stripe
+ // membership, or just clears the sub tracking when an admin/invite grant
+ // sits underneath (that access must survive a payment lapse).
+ if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err != nil {
+ continue
+ }
+ n++
+ s.prodTiersChanged(e.UserID) // their tabs learn their access ended
+ }
+ return n
+}
+
+// startBillingSweep runs the grace→revoke sweep hourly for the instance's life.
+func (s *Server) startBillingSweep() {
+ go func() {
+ t := time.NewTicker(time.Hour)
+ defer t.Stop()
+ for range t.C {
+ s.runBillingSweep(time.Now().Unix())
+ }
+ }()
+}
+
+// ---- Stripe Connect provider -------------------------------------------------
+
+// stripeConnect charges on the space admin's connected account (Stripe-Account
+// header), with a 10% application fee to the platform. Hand-rolled like
+// billing.go — the surface is a few form-encoded POSTs plus the shared
+// verifyStripeSignature. The connected account id lives in the space's config
+// (set at onboarding); with none set, nothing sells.
+type stripeConnect struct {
+ db *DB
+ key string // platform secret key
+ webhookSecret string
+ apiBase string
+ client *http.Client
+}
+
+// connectAccount is the space admin's Stripe connected account id ("acct_…"),
+// set when they link their account. Empty → the space can't sell yet.
+func (p *stripeConnect) connectAccount() string {
+ v, _, _ := p.db.getConfig("stripe_connect_account")
+ return v
+}
+
+func (p *stripeConnect) Configured() bool { return p.connectAccount() != "" }
+
+// post form-encodes to the Stripe API on the admin's connected account.
+func (p *stripeConnect) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
+ acct := p.connectAccount()
+ if acct == "" {
+ return nil, errBillingOff
+ }
+ req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+p.key)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Stripe-Account", acct) // charge on the admin's account
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
+ return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
+ }
+ if resp.StatusCode != http.StatusOK {
+ msg := ""
+ if e, ok := body["error"].(map[string]any); ok {
+ msg, _ = e["message"].(string)
+ }
+ return nil, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, msg)
+ }
+ return body, nil
+}
+
+func (p *stripeConnect) Checkout(ctx context.Context, r CheckoutReq) (string, error) {
+ form := url.Values{}
+ form.Set("mode", "subscription")
+ form.Set("line_items[0][price]", r.PriceRef)
+ form.Set("line_items[0][quantity]", "1")
+ form.Set("success_url", r.SuccessURL)
+ form.Set("cancel_url", r.CancelURL)
+ // The 10% platform fee, and the metadata that routes every later webhook
+ // back to (this member, this level).
+ form.Set("subscription_data[application_fee_percent]", applicationFeePercent)
+ form.Set("subscription_data[metadata][user_ref]", r.UserRef)
+ form.Set("subscription_data[metadata][tier_ref]", r.TierID)
+ form.Set("metadata[user_ref]", r.UserRef)
+ form.Set("metadata[tier_ref]", r.TierID)
+ body, err := p.post(ctx, "/v1/checkout/sessions", form)
+ if err != nil {
+ return "", err
+ }
+ u, _ := body["url"].(string)
+ if u == "" {
+ return "", fmt.Errorf("stripe: session created but no url")
+ }
+ return u, nil
+}
+
+func (p *stripeConnect) Cancel(ctx context.Context, extSub string) error {
+ // Cancel at period end: access continues until the paid period runs out, no
+ // further charge. (Stripe emits customer.subscription.updated with
+ // cancel_at_period_end=true, which our webhook turns into the ending state.)
+ form := url.Values{}
+ form.Set("cancel_at_period_end", "true")
+ _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
+ return err
+}
+
+func (p *stripeConnect) Resume(ctx context.Context, extSub string) error {
+ // Un-cancel: clear cancel_at_period_end on the still-live subscription. No new
+ // subscription, no new charge — the member simply keeps the one they have.
+ form := url.Values{}
+ form.Set("cancel_at_period_end", "false")
+ _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
+ return err
+}
+
+func (p *stripeConnect) SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error {
+ // Fetch the current item id (Stripe needs it to replace the price), then
+ // update the subscription's single item to the new price with proration,
+ // and re-point the metadata so the resulting webhook routes to the new
+ // level. (One item per membership subscription, by construction.)
+ sub, err := p.get(ctx, "/v1/subscriptions/"+url.PathEscape(extSub))
+ if err != nil {
+ return err
+ }
+ itemID := ""
+ if items, ok := sub["items"].(map[string]any); ok {
+ if data, ok := items["data"].([]any); ok && len(data) > 0 {
+ if it, ok := data[0].(map[string]any); ok {
+ itemID, _ = it["id"].(string)
+ }
+ }
+ }
+ if itemID == "" {
+ return fmt.Errorf("stripe: subscription %s has no item to switch", extSub)
+ }
+ form := url.Values{}
+ form.Set("items[0][id]", itemID)
+ form.Set("items[0][price]", newPriceRef)
+ form.Set("proration_behavior", "create_prorations")
+ form.Set("metadata[tier_ref]", newTierRef)
+ _, err = p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
+ return err
+}
+
+// get reads a Stripe object on the connected account.
+func (p *stripeConnect) get(ctx context.Context, path string) (map[string]any, error) {
+ acct := p.connectAccount()
+ if acct == "" {
+ return nil, errBillingOff
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+path, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+p.key)
+ req.Header.Set("Stripe-Account", acct)
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
+ return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
+ }
+ return body, nil
+}
+
+func (p *stripeConnect) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) {
+ form := url.Values{}
+ form.Set("customer", extCustomer)
+ form.Set("return_url", returnURL)
+ body, err := p.post(ctx, "/v1/billing_portal/sessions", form)
+ if err != nil {
+ return "", err
+ }
+ u, _ := body["url"].(string)
+ return u, nil
+}
+
+// ParseWebhook verifies the Connect webhook signature (shared with billing.go)
+// and normalizes checkout.session.completed + customer.subscription.* into
+// SubEvents. Unknown events → nil, nil (acknowledged, never retried).
+func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error) {
+ if !verifyStripeSignature(body, sig, p.webhookSecret, time.Now()) {
+ return nil, errors.New("webhook signature verification failed")
+ }
+ var ev struct {
+ Type string `json:"type"`
+ Account string `json:"account"` // the connected account this event is for
+ Created int64 `json:"created"`
+ Data struct {
+ Object json.RawMessage `json:"object"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &ev); err != nil {
+ return nil, err
+ }
+ // A Connect platform endpoint receives EVERY connected account's events, all
+ // signed with the one platform secret — so a valid signature is not proof the
+ // event is ours. Bind to THIS space's connected account, or an attacker who
+ // onboards their own account could forge metadata to grant free access. Drop
+ // (ack, don't retry) anything for another account.
+ if acct := p.connectAccount(); acct == "" || ev.Account != acct {
+ return nil, nil
+ }
+ switch {
+ case ev.Type == "checkout.session.completed":
+ var o struct {
+ Mode string `json:"mode"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ Metadata map[string]string `json:"metadata"`
+ }
+ if err := json.Unmarshal(ev.Data.Object, &o); err != nil || o.Mode != "subscription" {
+ return nil, nil
+ }
+ return []SubEvent{{
+ ExtSub: o.Subscription, ExtCustomer: o.Customer,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ Status: "active", Created: ev.Created, AllowCreate: true,
+ }}, nil
+ case strings.HasPrefix(ev.Type, "customer.subscription."):
+ var o struct {
+ ID string `json:"id"`
+ Customer string `json:"customer"`
+ Status string `json:"status"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ Metadata map[string]string `json:"metadata"`
+ Items struct {
+ Data []struct {
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ } `json:"data"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
+ return nil, err
+ }
+ cpe := o.CurrentPeriodEnd
+ if cpe == 0 && len(o.Items.Data) > 0 { // newer API: period end moved to the item
+ cpe = o.Items.Data[0].CurrentPeriodEnd
+ }
+ se := SubEvent{
+ ExtSub: o.ID, ExtCustomer: o.Customer,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ CurrentPeriodEnd: cpe,
+ CancelAtPeriodEnd: o.CancelAtPeriodEnd,
+ Deleted: ev.Type == "customer.subscription.deleted",
+ Created: ev.Created,
+ AllowCreate: ev.Type == "customer.subscription.created",
+ }
+ // Stripe's active|trialing keep access; past_due keeps it too but starts
+ // the grace clock; canceled/unpaid end it.
+ switch o.Status {
+ case "active", "trialing":
+ se.Status = "active"
+ case "past_due", "unpaid":
+ se.Status = "past_due"
+ default:
+ se.Status = "canceled"
+ }
+ return []SubEvent{se}, nil
+ }
+ return nil, nil
+}
+
+func (p *stripeConnect) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) {
+ // A recurring price with an inline product (product_data), so one call
+ // makes both. The connected account owns them.
+ form := url.Values{}
+ form.Set("unit_amount", strconv.Itoa(cents))
+ form.Set("currency", strings.ToLower(currency))
+ form.Set("recurring[interval]", interval)
+ form.Set("product_data[name]", name)
+ body, err := p.post(ctx, "/v1/prices", form)
+ if err != nil {
+ return "", err
+ }
+ id, _ := body["id"].(string)
+ if id == "" {
+ return "", fmt.Errorf("stripe: price created but no id")
+ }
+ return id, nil
+}
diff --git a/subscriptions_handlers.go b/subscriptions_handlers.go
new file mode 100644
index 0000000..142bead
--- /dev/null
+++ b/subscriptions_handlers.go
@@ -0,0 +1,446 @@
+package main
+
+// 🤖 HTTP surface for paid access levels (subscriptions.go). Admin: link the
+// Stripe account, set/clear a level's price. Member: subscribe, change level,
+// cancel, open the billing portal. Public: the Stripe Connect webhook (its
+// signature is the auth). All member/admin routes are token-gated; the whole
+// feature is dormant when no payment provider is configured.
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// supportedCurrency gates prices to 2-decimal currencies the UI renders. The
+// whole system treats amounts as minor units (cents/100); a zero-decimal
+// currency (JPY, KRW…) would be charged 100× at Stripe. Widen only alongside
+// zero-decimal handling.
+func supportedCurrency(code string) bool {
+ switch strings.ToLower(strings.TrimSpace(code)) {
+ case "usd", "eur", "gbp":
+ return true
+ }
+ return false
+}
+
+// origin reconstructs this instance's public origin for return URLs.
+func (s *Server) origin(r *http.Request) string {
+ scheme := "https"
+ // The router terminates TLS and proxies over a unix socket, so r.TLS is nil
+ // in production; trust the forwarded proto, defaulting to https.
+ if r.TLS == nil && !strings.HasPrefix(r.Host, "localhost") && r.Header.Get("X-Forwarded-Proto") == "http" {
+ scheme = "http"
+ }
+ return scheme + "://" + r.Host
+}
+
+// postBillingAccount (admin) links the space's Stripe connected account —
+// "acct_…", where members' payments land (the admin is merchant of record).
+// The full Stripe-hosted onboarding link belongs on the broker (design doc);
+// this stores the resulting account id so the feature can be wired now.
+func (s *Server) postBillingAccount(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Account string `json:"account"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ acct := strings.TrimSpace(body.Account)
+ if acct != "" && !strings.HasPrefix(acct, "acct_") {
+ httpError(w, http.StatusBadRequest, "an account id looks like acct_…")
+ return
+ }
+ if err := s.db.setConfig("stripe_connect_account", acct); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// getBillingStatus (admin) reports whether paid levels can be sold here: is a
+// provider configured at all, and is the space's connected account linked.
+func (s *Server) getBillingStatus(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.tierAdmin(w, r.URL.Query().Get("token")); !ok {
+ return
+ }
+ acct, _, _ := s.db.getConfig("stripe_connect_account")
+ _, platform := s.pay.(*stripeConnect) // a real provider is wired at the platform level
+ writeJSON(w, http.StatusOK, map[string]any{
+ "platform": platform, // Stripe keys present on this box
+ "available": s.pay != nil && s.pay.Configured(), // AND this space linked an account
+ "account": acct != "",
+ })
+}
+
+// postTierPrice (admin) sets or clears a level's subscription price. cents=0
+// clears it (admin/invite-only again). When a provider is configured and no
+// provider_ref is supplied, a Stripe Price is minted on the admin's account so
+// the admin never touches the dashboard.
+func (s *Server) postTierPrice(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Cents int `json:"cents"`
+ Currency string `json:"currency"`
+ Interval string `json:"interval"`
+ ProviderRef string `json:"provider_ref"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ tierID := r.PathValue("id")
+ if !s.db.tierExists(tierID) {
+ httpError(w, http.StatusBadRequest, "no such level")
+ return
+ }
+ if body.Cents == 0 { // clear the price → not sellable
+ if err := s.db.setTierPrice(tierID, 0, "", "", ""); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ return
+ }
+ // Only 2-decimal currencies we render — cents are minor units everywhere
+ // (price/100 in the UI, unit_amount at Stripe). A zero-decimal currency like
+ // JPY would be charged 100× (Stripe reads unit_amount as whole yen), so gate
+ // to the allowlist rather than accept any 3-letter code.
+ if body.Cents < 0 || body.Cents > 100_000_00 || (body.Interval != "month" && body.Interval != "year") ||
+ !supportedCurrency(body.Currency) {
+ httpError(w, http.StatusBadRequest, "bad price")
+ return
+ }
+ ref := strings.TrimSpace(body.ProviderRef)
+ if ref == "" && s.pay != nil && s.pay.Configured() {
+ created, err := s.pay.CreatePrice(r.Context(), s.db.tierName(tierID), body.Cents, body.Currency, body.Interval)
+ if err != nil {
+ httpError(w, http.StatusBadGateway, "couldn't set up the price with the payment provider")
+ return
+ }
+ ref = created
+ }
+ if err := s.db.setTierPrice(tierID, body.Cents, strings.ToLower(body.Currency), body.Interval, ref); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierSubscribe (member) starts a checkout for a sellable level. Returns a
+// hosted payment URL to send the member to; entitlement is granted by the
+// webhook once payment completes.
+func (s *Server) postTierSubscribe(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.memberFor(w, body.Token)
+ if !ok {
+ return
+ }
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
+ return
+ }
+ tierID := r.PathValue("id")
+ _, _, _, ref, sellable := s.db.tierPrice(tierID)
+ if !sellable || ref == "" {
+ httpError(w, http.StatusBadRequest, "this level isn't for sale")
+ return
+ }
+ // Never open a second checkout for a member who already has a subscription
+ // here — a fresh Checkout would create a duplicate live subscription and
+ // double-charge. An 'ending' (cancel-at-period-end) sub resumes; an active
+ // one is already theirs.
+ if ent, ok := s.db.memberEntitlement(tierID, uid); ok && ent.ExtSub != "" {
+ httpError(w, http.StatusConflict, "you already have a subscription to this level")
+ return
+ }
+ // They must hold a keypair so they can be granted the level key on
+ // activation — otherwise they'd pay and see nothing until they publish one.
+ if s.db.userPublicKey(uid) == "" {
+ httpError(w, http.StatusConflict, "your account is still setting up — try again in a moment")
+ return
+ }
+ origin := s.origin(r)
+ u, err := s.pay.Checkout(r.Context(), CheckoutReq{
+ TierID: tierID, PriceRef: ref, UserRef: uid,
+ SuccessURL: origin + "/?subscribed=1",
+ CancelURL: origin + "/",
+ })
+ if err != nil {
+ httpError(w, http.StatusBadGateway, "couldn't start checkout — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"url": u})
+}
+
+// postTierChange (member) switches an existing subscription to a different
+// level's price in place (prorated). Body names the target level.
+func (s *Server) postTierChange(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ ToTier string `json:"to_tier"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.memberFor(w, body.Token)
+ if !ok {
+ return
+ }
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
+ return
+ }
+ fromTier := r.PathValue("id")
+ ent, have := s.db.memberEntitlement(fromTier, uid)
+ if !have || ent.ExtSub == "" {
+ httpError(w, http.StatusBadRequest, "you don't have a subscription to change")
+ return
+ }
+ _, _, _, ref, sellable := s.db.tierPrice(body.ToTier)
+ if !sellable || ref == "" {
+ httpError(w, http.StatusBadRequest, "that level isn't for sale")
+ return
+ }
+ if err := s.pay.SwitchLevel(r.Context(), ent.ExtSub, ref, body.ToTier); err != nil {
+ httpError(w, http.StatusBadGateway, "couldn't change your plan — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierCancelSub (member) cancels their subscription at period end — access
+// continues until the paid period runs out (the webhook flips it to "ending").
+func (s *Server) postTierCancelSub(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.memberFor(w, body.Token)
+ if !ok {
+ return
+ }
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
+ return
+ }
+ ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
+ if !have || ent.ExtSub == "" {
+ httpError(w, http.StatusBadRequest, "no subscription to cancel")
+ return
+ }
+ if err := s.pay.Cancel(r.Context(), ent.ExtSub); err != nil {
+ httpError(w, http.StatusBadGateway, "couldn't cancel — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierResume (member) un-cancels a subscription still in its paid period —
+// clears cancel-at-period-end so it keeps renewing, no new charge. This is what
+// the "Resume" control does; it must never open a fresh checkout (which would
+// create a second, double-charging subscription).
+func (s *Server) postTierResume(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.memberFor(w, body.Token)
+ if !ok {
+ return
+ }
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
+ return
+ }
+ ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
+ if !have || ent.ExtSub == "" {
+ httpError(w, http.StatusBadRequest, "no subscription to resume")
+ return
+ }
+ if err := s.pay.Resume(r.Context(), ent.ExtSub); err != nil {
+ httpError(w, http.StatusBadGateway, "couldn't resume — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierPortal (member) returns a Stripe Billing Portal URL for managing the
+// card / invoices / cancellation of their subscription to this level.
+func (s *Server) postTierPortal(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.memberFor(w, body.Token)
+ if !ok {
+ return
+ }
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
+ return
+ }
+ ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
+ if !have || ent.ExtCustomer == "" {
+ httpError(w, http.StatusBadRequest, "no subscription to manage")
+ return
+ }
+ u, err := s.pay.PortalURL(r.Context(), ent.ExtCustomer, s.origin(r)+"/")
+ if err != nil || u == "" {
+ httpError(w, http.StatusBadGateway, "couldn't open billing — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"url": u})
+}
+
+// stripeConnectWebhook (public — the signature is the auth) drives entitlement
+// off the payment provider's subscription events. Reuses the tier machinery:
+// on active, assignTier + a key prod; on lapse/cancel past grace, unassignTier.
+func (s *Server) stripeConnectWebhook(w http.ResponseWriter, r *http.Request) {
+ if s.pay == nil || !s.pay.Configured() {
+ httpError(w, http.StatusNotImplemented, "billing not configured")
+ return
+ }
+ payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ httpError(w, http.StatusBadRequest, "unreadable body")
+ return
+ }
+ events, err := s.pay.ParseWebhook(r.Header.Get("Stripe-Signature"), payload)
+ if err != nil {
+ httpError(w, http.StatusBadRequest, "bad signature")
+ return
+ }
+ now := time.Now().Unix()
+ failed := false
+ for _, ev := range events {
+ if err := s.applyWebhookEvent(r.Context(), ev, now); err != nil {
+ failed = true
+ }
+ }
+ if failed {
+ // A member may have paid but not been activated — 500 so Stripe retries
+ // (a swallowed error under a 200 would strand the payment).
+ httpError(w, http.StatusInternalServerError, "event processing failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"received": true})
+}
+
+// applyWebhookEvent resolves the event to (tier, member) and folds it in,
+// returning an error only for a genuine processing failure (so the caller can
+// let the provider retry). The guards defend the money path against Connect's
+// realities — cross-account forgery, at-least-once + reordered delivery, and
+// events for members/subs that no longer match our records:
+//
+// - identity: resolve (tier,user) from event metadata, falling back to the
+// recorded subscription; require BOTH the tier and the user to still exist.
+// - ordering: ignore an event older than the last one folded for this sub
+// (Stripe redelivers and can reorder).
+// - no-resurrect: only a checkout may CREATE a membership; a bare
+// update/delete for a sub we no longer track is dropped (so a redelivered
+// pre-cancellation "active" can't revive a revoked membership).
+// - revoke safety: a lapse/cancel revokes ONLY if the event's subscription is
+// still the one we have on file (a superseded sub's late delete can't kick
+// a member who has since re-subscribed), and it keeps any admin/invite grant
+// underneath (revokeEntitlement is source-aware).
+func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64) error {
+ tierID, userID := ev.TierRef, ev.UserRef
+ bySub, haveBySub := s.db.entitlementBySub(ev.ExtSub)
+ if tierID == "" || userID == "" {
+ if haveBySub {
+ tierID, userID = bySub.TierID, bySub.UserID
+ }
+ }
+ if tierID == "" || userID == "" || !s.db.tierExists(tierID) || !s.db.userExists(userID) {
+ return nil // nothing actionable — ack, don't retry
+ }
+ // Ordering: drop an event strictly older than the last one folded for this
+ // subscription (Stripe redelivers and can reorder).
+ if haveBySub && ev.Created > 0 && bySub.EventTS > 0 && ev.Created < bySub.EventTS {
+ return nil
+ }
+ // When this event's subscription isn't one we track, decide before touching
+ // anything (applySubEvent would overwrite the recorded ext_sub):
+ // - member already holds a DIFFERENT current subscription here → this event
+ // is for a superseded/replaced sub; ignore it (a late delete for the old
+ // sub must not kick a member who re-subscribed).
+ // - member holds none here → only a genuine new-subscription signal may
+ // CREATE the membership; a bare update/delete is a stale redelivery that
+ // would resurrect a revoked membership, so drop it.
+ if !haveBySub {
+ if _, haveCur := s.db.memberEntitlement(tierID, userID); haveCur {
+ return nil
+ } else if !ev.AllowCreate {
+ return nil
+ }
+ }
+ // A level switch (SwitchLevel re-pointed the sub's metadata) moves the
+ // membership: drop the old level before the new one takes over.
+ if haveBySub && bySub.TierID != tierID {
+ if _, err := s.db.revokeEntitlement(bySub.TierID, userID); err != nil {
+ return err
+ }
+ s.prodTiersChanged(userID)
+ }
+ active, err := applySubEvent(s.db, ev, tierID, userID, now)
+ if err != nil {
+ return err // transient — let Stripe retry (webhook responds 500)
+ }
+ if active {
+ if err := s.db.assignTier(tierID, userID, "stripe"); err != nil {
+ return err
+ }
+ s.prodTierHolders(tierID, userID) // an online holder wraps the level key to them
+ s.prodTiersChanged(userID)
+ return nil
+ }
+ // Not active (past grace, or an outright cancel/deletion) — revoke, keeping
+ // any admin/invite grant underneath (revokeEntitlement is source-aware).
+ if _, err := s.db.revokeEntitlement(tierID, userID); err != nil {
+ return err
+ }
+ s.prodTiersChanged(userID)
+ return nil
+}
+
+// memberFor resolves a token to a member user id, writing the error otherwise.
+func (s *Server) memberFor(w http.ResponseWriter, token string) (string, bool) {
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return "", false
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return "", false
+ }
+ return uid, true
+}
diff --git a/subscriptions_test.go b/subscriptions_test.go
new file mode 100644
index 0000000..8c54242
--- /dev/null
+++ b/subscriptions_test.go
@@ -0,0 +1,389 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakePay is a scriptable PayProvider so the entitlement/webhook flow is
+// testable end-to-end without touching Stripe. Configured() true so handlers
+// don't early-return.
+type fakePay struct {
+ checkoutURL string
+ switched [][2]string // (extSub, newPriceRef) pairs seen by SwitchLevel
+ cancelled []string
+ resumed []string
+ events []SubEvent // returned by the next ParseWebhook
+}
+
+func (f *fakePay) Configured() bool { return true }
+func (f *fakePay) Checkout(context.Context, CheckoutReq) (string, error) {
+ return f.checkoutURL, nil
+}
+func (f *fakePay) Cancel(_ context.Context, sub string) error {
+ f.cancelled = append(f.cancelled, sub)
+ return nil
+}
+func (f *fakePay) Resume(_ context.Context, sub string) error {
+ f.resumed = append(f.resumed, sub)
+ return nil
+}
+func (f *fakePay) SwitchLevel(_ context.Context, sub, price, _ string) error {
+ f.switched = append(f.switched, [2]string{sub, price})
+ return nil
+}
+func (f *fakePay) CreatePrice(context.Context, string, int, string, string) (string, error) {
+ return "price_created", nil
+}
+func (f *fakePay) PortalURL(context.Context, string, string) (string, error) {
+ return "https://portal.example/x", nil
+}
+func (f *fakePay) ParseWebhook(string, []byte) ([]SubEvent, error) { return f.events, nil }
+
+// mkTierMember seeds a level + a keyed member; returns (tierID, uid).
+func seedTierAndMember(t *testing.T, srv *Server, level, label string) (string, string) {
+ t.Helper()
+ tid, err := srv.db.createTier(level)
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey: %v", err)
+ }
+ return tid, u
+}
+
+// TestEntitlementStateMachine drives applySubEvent through the lifecycle that
+// docs/community-and-tiers.md § revocation specifies, with no Stripe.
+func TestEntitlementStateMachine(t *testing.T) {
+ _, srv := newTestServer(t)
+ billingGrace = 30 * 24 * time.Hour
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(1_000_000)
+ month := int64(30 * 24 * 3600)
+
+ // 1. Active, healthy → access, no end.
+ active, err := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", ExtCustomer: "cus1", Status: "active", CurrentPeriodEnd: now + month}, tid, uid, now)
+ if err != nil || !active {
+ t.Fatalf("active: active=%v err=%v", active, err)
+ }
+ e, ok := srv.db.memberEntitlement(tid, uid)
+ if !ok || e.Status != "active" || e.PaidUntil != now+month || e.GraceDeadline != 0 {
+ t.Fatalf("after active: %+v ok=%v", e, ok)
+ }
+
+ // 2. Cancel at period end → still active until the period end, then it ends
+ // there (no bonus grace).
+ active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now + month}, tid, uid, now)
+ e, _ = srv.db.memberEntitlement(tid, uid)
+ if !active || e.Status != "canceled" || e.GraceDeadline != now+month {
+ t.Fatalf("cancel-at-period-end: active=%v %+v", active, e)
+ }
+ // …and past that period end, not active.
+ active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now}, tid, uid, now+1)
+ if active {
+ t.Fatalf("cancel past period end should be inactive")
+ }
+
+ // 3. Payment failure → 30-day grace from the paid-until, active during it.
+ active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now)
+ e, _ = srv.db.memberEntitlement(tid, uid)
+ if !active || e.Status != "past_due" || e.GraceDeadline != now+int64(billingGrace.Seconds()) {
+ t.Fatalf("past_due: active=%v %+v", active, e)
+ }
+ if a, _ := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now+int64(billingGrace.Seconds())+1); a {
+ t.Fatalf("past grace should be inactive")
+ }
+
+ // 4. Outright cancel/delete → inactive, revoke on next sweep.
+ active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "canceled", Deleted: true, CurrentPeriodEnd: now}, tid, uid, now)
+ e, _ = srv.db.memberEntitlement(tid, uid)
+ if active || e.GraceDeadline != now {
+ t.Fatalf("deleted: active=%v %+v", active, e)
+ }
+}
+
+// TestBillingSweepRevokes: a membership past its grace deadline is unassigned
+// (key + membership dropped) by the sweep; one still in grace is left alone.
+func TestBillingSweepRevokes(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal")
+ now := int64(2_000_000)
+
+ // Bea: past grace. Cal: still within grace.
+ srv.db.assignTier(tid, uid, "stripe")
+ srv.db.grantTierKey(tid, uid, "wrapped-bea")
+ srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "past_due", PaidUntil: now - 100, GraceDeadline: now - 1, ExtSub: "sub-bea"})
+ srv.db.assignTier(tid2, uid2, "stripe")
+ srv.db.grantTierKey(tid2, uid2, "wrapped-cal")
+ srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "past_due", PaidUntil: now, GraceDeadline: now + 1000, ExtSub: "sub-cal"})
+
+ if n := srv.runBillingSweep(now); n != 1 {
+ t.Fatalf("sweep revoked %d, want 1", n)
+ }
+ if srv.db.tierMember(tid, uid) {
+ t.Fatalf("Bea should have been revoked")
+ }
+ if srv.db.tierHolder(tid, uid) {
+ t.Fatalf("Bea's key row should be gone")
+ }
+ if !srv.db.tierMember(tid2, uid2) {
+ t.Fatalf("Cal (in grace) should be kept")
+ }
+}
+
+// TestWebhookActivatesAndMigrates: a checkout activates the membership (assign
+// + key prod path via assignTier), and a switch-level event migrates it.
+func TestWebhookActivates(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(3_000_000)
+
+ // Activate via a normalized checkout event carrying our refs (AllowCreate —
+ // only a genuine new-subscription signal creates a membership).
+ srv.applyWebhookEvent(context.Background(), SubEvent{
+ ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid,
+ Status: "active", CurrentPeriodEnd: now + 100000, AllowCreate: true, Created: now,
+ }, now)
+ if !srv.db.tierMember(tid, uid) {
+ t.Fatalf("member not assigned after active webhook")
+ }
+ e, ok := srv.db.memberEntitlement(tid, uid)
+ if !ok || e.Status != "active" || e.ExtSub != "sub9" {
+ t.Fatalf("entitlement after activate: %+v ok=%v", e, ok)
+ }
+
+ // Switch level: same sub, new tier metadata → old membership dropped, new one on.
+ tid2, err := srv.db.createTier("Platinum")
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+ srv.applyWebhookEvent(context.Background(), SubEvent{
+ ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid2,
+ Status: "active", CurrentPeriodEnd: now + 100000,
+ }, now)
+ if srv.db.tierMember(tid, uid) {
+ t.Fatalf("old level should be dropped after switch")
+ }
+ if !srv.db.tierMember(tid2, uid) {
+ t.Fatalf("new level should be assigned after switch")
+ }
+
+ // A delete revokes.
+ srv.applyWebhookEvent(context.Background(), SubEvent{
+ ExtSub: "sub9", UserRef: uid, TierRef: tid2, Status: "canceled", Deleted: true,
+ }, now)
+ if srv.db.tierMember(tid2, uid) {
+ t.Fatalf("delete should revoke")
+ }
+}
+
+// TestSubscribeHandlers exercises the HTTP surface with fakePay: admin sets a
+// price, a member subscribes (gets the checkout URL), and cancel routes to the
+// provider. Confirms the endpoints are wired + gated.
+func TestSubscribeHandlers(t *testing.T) {
+ ts, srv := newTestServer(t)
+ fake := &fakePay{checkoutURL: "https://pay.example/checkout"}
+ srv.pay = fake
+
+ mkUser := func(label string) (tok, uid string) {
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite: %v", err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey: %v", err)
+ }
+ return tk, u
+ }
+ post := func(path, body string) (int, map[string]any) {
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post %s: %v", path, err)
+ }
+ defer resp.Body.Close()
+ var out map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return resp.StatusCode, out
+ }
+
+ tokAdmin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ tokBea, uidBea := mkUser("Bea")
+ tid, _ := srv.db.createTier("Gold")
+
+ // A member can't set a price; an admin can (fakePay mints the provider ref).
+ if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokBea+`","cents":500,"currency":"eur","interval":"month"}`); code != 403 {
+ t.Fatalf("member set price: %d, want 403", code)
+ }
+ if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":500,"currency":"eur","interval":"month"}`); code != 200 {
+ t.Fatalf("admin set price: %d", code)
+ }
+ cents, _, _, ref, sellable := srv.db.tierPrice(tid)
+ if cents != 500 || ref != "price_created" || !sellable {
+ t.Fatalf("price stored wrong: %d %q %v", cents, ref, sellable)
+ }
+
+ // Member subscribe → checkout URL.
+ code, out := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`)
+ if code != 200 || out["url"] != "https://pay.example/checkout" {
+ t.Fatalf("subscribe: %d %v", code, out)
+ }
+
+ // Simulate the webhook activating Bea, then cancel routes to the provider.
+ srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uidBea, TierRef: tid, Status: "active", CurrentPeriodEnd: 9999999999, AllowCreate: true, Created: 1000}, 1000)
+ if code, _ := post("/api/tiers/"+tid+"/cancel-subscription", `{"token":"`+tokBea+`"}`); code != 200 {
+ t.Fatalf("cancel: %d", code)
+ }
+ if len(fake.cancelled) != 1 || fake.cancelled[0] != "s1" {
+ t.Fatalf("cancel didn't route to provider: %v", fake.cancelled)
+ }
+
+ // Bea already holds a subscription here → a second subscribe is refused (no
+ // duplicate checkout, no double charge), and Resume routes to the provider.
+ if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 409 {
+ t.Fatalf("duplicate subscribe: %d, want 409", code)
+ }
+ if code, _ := post("/api/tiers/"+tid+"/resume", `{"token":"`+tokBea+`"}`); code != 200 {
+ t.Fatalf("resume: %d", code)
+ }
+ if len(fake.resumed) != 1 || fake.resumed[0] != "s1" {
+ t.Fatalf("resume didn't route to provider: %v", fake.resumed)
+ }
+
+ // A zero-decimal currency (would be charged 100×) is rejected.
+ if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":50000,"currency":"jpy","interval":"month"}`); code != 400 {
+ t.Fatalf("jpy price: %d, want 400", code)
+ }
+
+ // Dormant provider → subscribe refuses cleanly.
+ srv.pay = noPay{}
+ if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 503 {
+ t.Fatalf("dormant subscribe: %d, want 503", code)
+ }
+}
+
+// TestWebhookNoResurrect: after a subscription is revoked (row gone), a stale
+// redelivered "active" (a bare update, not a new-subscription signal) must NOT
+// re-create the membership.
+func TestWebhookNoResurrect(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(5_000_000)
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatalf("applyWebhookEvent: %v", err)
+ }
+ }
+ must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now))
+ must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10))
+ if srv.db.tierMember(tid, uid) {
+ t.Fatalf("should be revoked after delete")
+ }
+ // Stale redelivered active (older, no AllowCreate) → dropped, not resurrected.
+ must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, Created: now + 5}, now+20))
+ if srv.db.tierMember(tid, uid) {
+ t.Fatalf("stale active resurrected a revoked membership")
+ }
+}
+
+// TestWebhookSupersededSubIgnored: a member active on a NEW subscription isn't
+// kicked (nor is their record corrupted) by a late event for the OLD one.
+func TestWebhookSupersededSubIgnored(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(6_000_000)
+ srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S2", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)
+ // Late delete for the superseded S1 must not touch the S2 membership.
+ srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 5}, now+5)
+ if !srv.db.tierMember(tid, uid) {
+ t.Fatalf("member on S2 wrongly revoked by S1's delete")
+ }
+ e, _ := srv.db.memberEntitlement(tid, uid)
+ if e.ExtSub != "S2" || e.Status != "active" {
+ t.Fatalf("S1 event corrupted the S2 entitlement: %+v", e)
+ }
+}
+
+// TestSubscriptionLapseKeepsAdminGrant: a paid subscription over an admin comp
+// tracks its lifecycle but never flips the sticky source, so a lapse clears the
+// subscription yet keeps the admin-granted access + key.
+func TestSubscriptionLapseKeepsAdminGrant(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(7_000_000)
+ if err := srv.db.assignTier(tid, uid, "admin"); err != nil {
+ t.Fatalf("assignTier: %v", err)
+ }
+ srv.db.grantTierKey(tid, uid, "wrapped")
+ srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)
+ if src, _ := srv.db.tierSource(tid, uid); src != "admin" {
+ t.Fatalf("source flipped to %q, want admin", src)
+ }
+ srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10)
+ if !srv.db.tierMember(tid, uid) || !srv.db.tierHolder(tid, uid) {
+ t.Fatalf("admin-granted access/key wrongly removed on subscription lapse")
+ }
+ if e, ok := srv.db.memberEntitlement(tid, uid); ok && e.ExtSub != "" {
+ t.Fatalf("subscription tracking not cleared: %+v", e)
+ }
+}
+
+// TestPaidUntilZeroGuard: a checkout event (no period end) arriving after an
+// update that already stored the renewal date must not zero it.
+func TestPaidUntilZeroGuard(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea")
+ now := int64(8_000_000)
+ applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: now + 5000, Created: now}, tid, uid, now)
+ applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: 0, Created: now + 1}, tid, uid, now+1)
+ if e, _ := srv.db.memberEntitlement(tid, uid); e.PaidUntil != now+5000 {
+ t.Fatalf("paid_until zeroed to %d, want %d", e.PaidUntil, now+5000)
+ }
+}
+
+// TestBillingSweepBackstop: the sweep revokes a subscription whose paid period
+// lapsed more than the grace window ago even if no lapse webhook ever arrived
+// (grace_deadline still 0) — the true backstop — while leaving a healthy sub.
+func TestBillingSweepBackstop(t *testing.T) {
+ _, srv := newTestServer(t)
+ billingGrace = 30 * 24 * time.Hour
+ grace := int64(billingGrace.Seconds())
+ now := int64(9_000_000)
+ tid, uid := seedTierAndMember(t, srv, "Gold", "bea") // silently lapsed
+ tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal") // healthy
+ srv.db.assignTier(tid, uid, "stripe")
+ srv.db.grantTierKey(tid, uid, "w")
+ srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "active", PaidUntil: now - grace - 100, GraceDeadline: 0, ExtSub: "s1"})
+ srv.db.assignTier(tid2, uid2, "stripe")
+ srv.db.grantTierKey(tid2, uid2, "w2")
+ srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "active", PaidUntil: now + grace + 100000, GraceDeadline: 0, ExtSub: "s2"})
+ if n := srv.runBillingSweep(now); n != 1 {
+ t.Fatalf("backstop sweep revoked %d, want 1", n)
+ }
+ if srv.db.tierMember(tid, uid) {
+ t.Fatalf("silently-lapsed sub not revoked")
+ }
+ if !srv.db.tierMember(tid2, uid2) {
+ t.Fatalf("healthy sub wrongly revoked")
+ }
+}
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 721a743..c238b54 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -430,6 +430,34 @@ export const app = defineApp({
levelsNote.textContent =
"To make a group limited to a level, you need to be a member of that level yourself.";
levelsSec.appendChild(levelsNote);
+ // Payments: whether members can be charged for a level here. When the
+ // platform supports it but this space hasn't linked a Stripe account, offer
+ // to link one; when it's linked, levels get a "Set a price" control (below).
+ // billingReady gates the per-level price UI in buildLevelBox.
+ let billingReady = false;
+ const paySec = document.createElement("div");
+ paySec.style.cssText = "margin:0 0 12px";
+ levelsSec.appendChild(paySec);
+ const refreshPay = async () => {
+ const st = await bridge.billingStatus();
+ billingReady = !!(st && st.available);
+ paySec.textContent = "";
+ if (!st || !st.platform) return; // payments not offered on this box — say nothing
+ const row = document.createElement("div");
+ row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--muted)";
+ const txt = document.createElement("span");
+ txt.textContent = billingReady
+ ? "Paid access levels are on — members pay your Stripe account (we take 10%)."
+ : "Charge for access levels: link your Stripe account to start.";
+ row.appendChild(txt);
+ const link = document.createElement("button");
+ link.className = "linkish";
+ link.textContent = billingReady ? "Change Stripe account" : "Link Stripe account";
+ link.onclick = () => editStripeAccount(refreshPay);
+ row.appendChild(link);
+ paySec.appendChild(row);
+ };
+
const levelsList = document.createElement("div");
levelsSec.appendChild(levelsList);
const levelAddRow = document.createElement("div");
@@ -529,6 +557,42 @@ export const app = defineApp({
visRow.append(visLabel, visSel);
box.appendChild(visRow);
+ // Price: make a level sellable so members can subscribe. Only shown once
+ // payments are set up for the space (billingReady); otherwise a level is
+ // admin/invite-assigned only. Members pay the admin's Stripe account; the
+ // platform takes a 10% fee.
+ if (billingReady) {
+ const priceRow = document.createElement("div");
+ priceRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap";
+ const priceLabel = document.createElement("span");
+ priceLabel.style.cssText = "font-size:12px;color:var(--muted)";
+ priceLabel.textContent = "Members can subscribe:";
+ const cur = level.price_cents
+ ? `${(level.price_cents / 100).toFixed(level.price_cents % 100 ? 2 : 0)} ${(level.currency || "").toUpperCase()} / ${level.interval || "month"}`
+ : "Not for sale";
+ const priceVal = document.createElement("strong");
+ priceVal.style.fontSize = "13px";
+ priceVal.textContent = cur;
+ const setBtn = document.createElement("button");
+ setBtn.className = "linkish";
+ setBtn.textContent = level.price_cents ? "Change" : "Set a price";
+ setBtn.onclick = () => editLevelPrice(level, () => paintLevels());
+ priceRow.append(priceLabel, priceVal, setBtn);
+ if (level.price_cents) {
+ const clr = document.createElement("button");
+ clr.className = "linkish danger";
+ clr.textContent = "Stop selling";
+ clr.onclick = async () => {
+ clr.disabled = true;
+ const ok = await bridge.setTierPrice(level.id, { cents: 0 });
+ flash(levelsStatus, ok ? "No longer for sale." : "Couldn't update — try again.");
+ await paintLevels();
+ };
+ priceRow.appendChild(clr);
+ }
+ box.appendChild(priceRow);
+ }
+
if (level.key_lost) {
// Minted once, every holder gone — unrecoverable by design (keys
// never re-mint under groups wrapped to the old one). Say so.
@@ -625,7 +689,117 @@ export const app = defineApp({
return box;
};
+ // A minimal admin modal (its own backdrop, like showInviteModal) for the
+ // two billing config flows. onSave(value) returns truthy to close.
+ const configModal = (title, fields, onSave) => {
+ const backdrop = document.createElement("div");
+ backdrop.style.cssText =
+ "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px";
+ const cardEl = document.createElement("div");
+ cardEl.style.cssText = "background:var(--panel);border-radius:14px;padding:22px;max-width:340px;width:100%;box-shadow:0 10px 40px rgba(0,0,0,0.3)";
+ const hh = document.createElement("h3");
+ hh.textContent = title;
+ hh.style.cssText = "margin:0 0 14px;font-size:17px;font-weight:700";
+ cardEl.appendChild(hh);
+ const inputs = {};
+ for (const f of fields) {
+ const lab = document.createElement("label");
+ lab.className = "field-label";
+ lab.textContent = f.label;
+ cardEl.appendChild(lab);
+ let el;
+ if (f.options) {
+ el = document.createElement("select");
+ for (const o of f.options) {
+ const opt = document.createElement("option");
+ opt.value = o.value;
+ opt.textContent = o.label;
+ el.appendChild(opt);
+ }
+ } else {
+ el = document.createElement("input");
+ el.type = f.type || "text";
+ if (f.placeholder) el.placeholder = f.placeholder;
+ }
+ if (f.value != null) el.value = f.value;
+ el.style.cssText = "width:100%;margin-bottom:10px";
+ cardEl.appendChild(el);
+ inputs[f.key] = el;
+ }
+ const st = statusLine(cardEl);
+ const foot = document.createElement("div");
+ foot.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:8px";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ save.onclick = async () => {
+ save.disabled = true;
+ const vals = {};
+ for (const k of Object.keys(inputs)) vals[k] = inputs[k].value.trim();
+ const ok = await onSave(vals);
+ if (ok) backdrop.remove();
+ else {
+ save.disabled = false;
+ flash(st, "Couldn't save — check the values and try again.");
+ }
+ };
+ foot.append(cancel, save);
+ cardEl.appendChild(foot);
+ backdrop.appendChild(cardEl);
+ backdrop.addEventListener("click", (e) => e.target === backdrop && backdrop.remove());
+ document.body.appendChild(backdrop);
+ const first = cardEl.querySelector("input,select");
+ if (first) first.focus();
+ };
+
+ const editStripeAccount = (done) =>
+ configModal(
+ "Link your Stripe account",
+ [{ key: "account", label: "Stripe account id (acct_…)", placeholder: "acct_…", value: "" }],
+ async (v) => {
+ const ok = await bridge.setBillingAccount(v.account);
+ if (ok) await done();
+ return ok;
+ },
+ );
+
+ const editLevelPrice = (level, done) =>
+ configModal(
+ `Price for ${level.name}`,
+ [
+ { key: "amount", label: "Amount", type: "number", value: level.price_cents ? level.price_cents / 100 : "" },
+ {
+ key: "currency",
+ label: "Currency",
+ options: [
+ { value: "eur", label: "EUR (€)" },
+ { value: "usd", label: "USD ($)" },
+ { value: "gbp", label: "GBP (£)" },
+ ],
+ },
+ {
+ key: "interval",
+ label: "Billed",
+ options: [
+ { value: "month", label: "Monthly" },
+ { value: "year", label: "Yearly" },
+ ],
+ },
+ ],
+ async (v) => {
+ const cents = Math.round(parseFloat(v.amount) * 100);
+ if (!(cents > 0)) return false;
+ const ok = await bridge.setTierPrice(level.id, { cents, currency: v.currency, interval: v.interval });
+ if (ok) await done();
+ return ok;
+ },
+ );
+
const paintLevels = async () => {
+ await refreshPay();
const levels = await bridge.tiers();
people = bridge.levelMembers();
levelsList.textContent = "";
diff --git a/web/static/shell.js b/web/static/shell.js
index 70e2f6e..d49023a 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1506,6 +1506,49 @@ async function setTierShown(id, show) {
}
}
+// ---- paid access levels: member billing calls (subscriptions.go) ----------
+// Each returns {url} for a redirect, true for a plain ok, or null on failure.
+async function billingPost(path, body) {
+ try {
+ const r = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, ...body }),
+ });
+ if (!r.ok) return null;
+ return await r.json().catch(() => ({}));
+ } catch {
+ return null;
+ }
+}
+const subscribeToLevel = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/subscribe`, {});
+const cancelLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/cancel-subscription`, {});
+// Resume un-cancels the EXISTING subscription (clears cancel-at-period-end) —
+// never a fresh checkout, which would open a second, double-charging sub.
+const resumeLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/resume`, {});
+const changeLevelSub = (fromId, toId) => billingPost(`/api/tiers/${encodeURIComponent(fromId)}/change`, { to_tier: toId });
+const levelPortal = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/portal`, {});
+
+// A calm, dated day like "5 February 2026" from unix seconds.
+function billingDate(unixSecs) {
+ if (!unixSecs) return "";
+ try {
+ return new Date(unixSecs * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
+ } catch {
+ return "";
+ }
+}
+
+// A level's price as "€5 / month" (symbol-lite: uses the currency code when we
+// don't know the symbol — clearer than guessing).
+function levelPrice(t) {
+ if (!t.price_cents) return "";
+ const amount = (t.price_cents / 100).toFixed(t.price_cents % 100 ? 2 : 0);
+ const sym = { usd: "$", eur: "€", gbp: "£" }[(t.currency || "").toLowerCase()] || (t.currency || "").toUpperCase() + " ";
+ const per = t.interval === "year" ? "year" : "month";
+ return { text: `${sym}${amount}`, per };
+}
+
// Make sure every member of a level holds its key, as far as THIS client can:
// wrap with our copy if we hold one, or mint if nobody holds one yet (the mint
// moment — the server allows it for admins only). A minting admin who isn't a
@@ -1925,6 +1968,8 @@ window.lchatPaletteItems = () => {
const actions = [{ id: "new-chat", label: "New chat" }];
if (others) actions.push({ id: "new-group", label: "New group" });
actions.push({ id: "discover", label: "Discover groups" });
+ // Membership: only when the space sells any level, or you already subscribe.
+ if (tiers.some((t) => t.price_cents > 0 || t.status)) actions.push({ id: "membership", label: "Membership" });
if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" });
if (ENABLE_APPS && personalApps().length) actions.push({ id: "apps", label: "Open apps" });
// Deliberately outside the apps gate: Admin is how an admin would switch
@@ -1944,6 +1989,7 @@ window.lchatPaletteRun = (kind, id) => {
"new-chat": () => showNewChat(),
"new-group": () => showNewChat("group"),
discover: showDiscover,
+ membership: showMembership,
invite: () => showNewChat("invite"),
apps: showApps,
admin: async () => {
@@ -4271,6 +4317,41 @@ setSetupBridge({
return false;
}
},
+ // Paid access levels (admin): whether payments are set up for this space,
+ // set/clear a level's price, and link the Stripe account.
+ billingStatus: async () => {
+ try {
+ return await (await fetch(`/api/admin/billing?token=${encodeURIComponent(session.token)}`)).json();
+ } catch {
+ return { platform: false, available: false, account: false };
+ }
+ },
+ setTierPrice: async (id, price) => {
+ try {
+ return (
+ await fetch(`/api/tiers/${encodeURIComponent(id)}/price`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, ...price }),
+ })
+ ).ok;
+ } catch {
+ return false;
+ }
+ },
+ setBillingAccount: async (account) => {
+ try {
+ return (
+ await fetch("/api/admin/billing/account", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, account }),
+ })
+ ).ok;
+ } catch {
+ return false;
+ }
+ },
// A member's own show/hide for a level (honoured only under the "user" policy).
setTierShown: async (id, show) => {
try {
@@ -8037,6 +8118,268 @@ const disc = document.createElement("button");
// directory of open groups — join any of them on the spot. Each row's name
// decrypts via its open_key + the instance key, so the whole directory is
// readable by any member and by nobody else (the server sees only blobs).
+// Membership: the member's paid-access-levels surface. Their current
+// subscription (with the dated "renews / ends on" line — the reassuring
+// heart), plus any levels they can subscribe to. Reachable from Edit profile
+// and ⌘K when the space sells any level or the member already subscribes.
+// Anti-dark-pattern: cancel is a plain, equal-weight action and always states
+// the exact keep-until date; price + interval are shown before any commitment.
+async function showMembership() {
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = "Membership";
+ head.appendChild(h);
+ card.appendChild(head);
+
+ const status = statusLineEl();
+ card.appendChild(status);
+
+ const paint = async () => {
+ await loadTiers();
+ // Clear everything below the header + status line.
+ while (status.nextSibling) status.nextSibling.remove();
+
+ const sellable = tiers.filter((t) => t.price_cents > 0);
+ const subscribed = tiers.filter((t) => t.status); // active | past_due | canceled
+ if (!sellable.length && !subscribed.length) {
+ const p = document.createElement("p");
+ p.className = "membership-note";
+ p.textContent = "This space doesn't offer paid access levels.";
+ card.appendChild(p);
+ return;
+ }
+
+ const note = document.createElement("p");
+ note.className = "membership-note";
+ note.textContent = "Subscriptions are handled securely by Stripe. You can change or cancel anytime.";
+ card.appendChild(note);
+
+ const busy = (btn, fn) => async () => {
+ btn.disabled = true;
+ const prev = btn.textContent;
+ btn.textContent = "…";
+ const res = await fn();
+ if (res && res.url) {
+ location.href = res.url; // hosted checkout / billing portal
+ return;
+ }
+ btn.disabled = false;
+ btn.textContent = prev;
+ if (res) {
+ flash(status, "Done.");
+ await paint();
+ } else {
+ flash(status, "Something went wrong — try again.");
+ }
+ };
+
+ // ---- Your subscription (current state) --------------------------------
+ for (const t of subscribed) {
+ const box = document.createElement("div");
+ box.className = "plan-card current";
+ const hd = document.createElement("div");
+ hd.className = "plan-head";
+ const nm = document.createElement("span");
+ nm.className = "plan-name";
+ nm.textContent = t.name;
+ hd.appendChild(nm);
+ const pr = levelPrice(t);
+ if (pr) {
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = pr.text;
+ const per = document.createElement("span");
+ per.className = "per";
+ per.textContent = " / " + pr.per;
+ price.appendChild(per);
+ hd.appendChild(price);
+ }
+ box.appendChild(hd);
+
+ // The dated status line — icon (shape) + words, never colour alone.
+ const st = document.createElement("div");
+ st.className = "plan-state";
+ const dot = document.createElement("span");
+ dot.className = "dot";
+ st.appendChild(dot);
+ const txt = document.createElement("span");
+ if (t.status === "active") {
+ txt.textContent = t.paid_until ? `Renews on ${billingDate(t.paid_until)}` : "Active";
+ } else if (t.status === "past_due") {
+ st.classList.add("warn");
+ dot.textContent = "";
+ const warn = document.createElement("span");
+ warn.setAttribute("aria-hidden", "true");
+ warn.textContent = "⚠ ";
+ st.replaceChild(warn, dot);
+ txt.textContent = `Payment didn't go through — access ends ${billingDate(t.grace_deadline)} unless it's fixed.`;
+ } else {
+ // canceled / ending
+ txt.textContent = `Your access ends on ${billingDate(t.grace_deadline || t.paid_until)}.`;
+ }
+ st.appendChild(txt);
+ box.appendChild(st);
+
+ const acts = document.createElement("div");
+ acts.className = "plan-actions";
+ if (t.status === "active") {
+ // Change level (to another sellable level), Cancel, Manage billing.
+ const others = sellable.filter((x) => x.id !== t.id);
+ if (others.length) {
+ const chg = pillBtn("Change level", true);
+ chg.onclick = () => showChangeLevel(t, others, paint, status);
+ acts.appendChild(chg);
+ }
+ const cancel = pillBtn("Cancel", true);
+ cancel.onclick = () => confirmCancel(t, paint, status);
+ acts.appendChild(cancel);
+ } else if (t.status === "past_due") {
+ const fix = pillBtn("Update payment", false);
+ fix.onclick = busy(fix, () => levelPortal(t.id));
+ acts.appendChild(fix);
+ } else {
+ // ending — offer to resume (un-cancels the same subscription, no new charge).
+ const resume = pillBtn("Resume", false);
+ resume.onclick = busy(resume, () => resumeLevelSub(t.id));
+ acts.appendChild(resume);
+ }
+ const manage = pillBtn("Manage billing", true);
+ manage.onclick = busy(manage, () => levelPortal(t.id));
+ acts.appendChild(manage);
+ box.appendChild(acts);
+ card.appendChild(box);
+ }
+
+ // ---- Levels you can subscribe to --------------------------------------
+ const canSubscribe = sellable.filter((t) => !t.status);
+ if (canSubscribe.length) {
+ if (subscribed.length) {
+ const sec = document.createElement("div");
+ sec.className = "membership-sec";
+ sec.textContent = "Other access levels";
+ card.appendChild(sec);
+ }
+ for (const t of canSubscribe) {
+ const box = document.createElement("div");
+ box.className = "plan-card";
+ const hd = document.createElement("div");
+ hd.className = "plan-head";
+ const nm = document.createElement("span");
+ nm.className = "plan-name";
+ nm.textContent = t.name;
+ hd.appendChild(nm);
+ const pr = levelPrice(t);
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = pr.text;
+ const per = document.createElement("span");
+ per.className = "per";
+ per.textContent = " / " + pr.per;
+ price.appendChild(per);
+ hd.appendChild(price);
+ box.appendChild(hd);
+ const acts = document.createElement("div");
+ acts.className = "plan-actions";
+ const sub = pillBtn("Subscribe", false);
+ sub.onclick = busy(sub, () => subscribeToLevel(t.id));
+ acts.appendChild(sub);
+ box.appendChild(acts);
+ card.appendChild(box);
+ }
+ }
+ };
+ await paint();
+}
+
+// A quiet pill button; loud=accent (the one prominent action), else the quiet
+// variant. Reuses the .pill-btn vocabulary from Discover.
+function pillBtn(label, quiet) {
+ const b = document.createElement("button");
+ b.className = "pill-btn" + (quiet ? " quiet" : "");
+ b.textContent = label;
+ return b;
+}
+
+// A small inline status line (reused across the membership flows).
+function statusLineEl() {
+ const p = document.createElement("div");
+ p.className = "admin-status";
+ p.setAttribute("role", "status");
+ p.style.cssText = "min-height:18px;font-size:13px;color:var(--muted)";
+ return p;
+}
+
+// Show a transient message in an inline status element (never a floating toast).
+function flash(el, msg) {
+ if (!el) return;
+ el.textContent = msg;
+ setTimeout(() => {
+ if (el.textContent === msg) el.textContent = "";
+ }, 2500);
+}
+
+// Cancel confirmation as a DATED PROMISE, not a threat: it names the exact day
+// access continues to, so cancelling never feels like losing everything now.
+function confirmCancel(t, repaint, status) {
+ const until = billingDate(t.paid_until);
+ const msg = until
+ ? `You'll keep ${t.name} until ${until}. Cancel your subscription?`
+ : `Cancel your ${t.name} subscription?`;
+ if (!confirm(msg)) return;
+ flash(status, "Cancelling…");
+ cancelLevelSub(t.id).then((ok) => {
+ flash(status, ok ? "Cancelled — you keep access until it ends." : "Couldn't cancel — try again.");
+ repaint();
+ });
+}
+
+// Change level: pick another sellable level; switches the subscription in place
+// (Stripe prorates the difference).
+function showChangeLevel(from, others, repaint, status) {
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Change level";
+ card.appendChild(h);
+ const note = document.createElement("p");
+ note.className = "membership-note";
+ note.textContent = `You're on ${from.name}. Switching adjusts your next bill for the difference.`;
+ card.appendChild(note);
+ for (const t of others) {
+ const row = document.createElement("button");
+ row.className = "list-row";
+ const nm = document.createElement("span");
+ nm.style.flex = "1";
+ nm.textContent = t.name;
+ const pr = levelPrice(t);
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = `${pr.text} / ${pr.per}`;
+ row.append(nm, price);
+ row.onclick = () => {
+ if (!confirm(`Switch to ${t.name} (${pr.text} / ${pr.per})?`)) return;
+ backdrop.remove();
+ flash(status, "Switching…");
+ changeLevelSub(from.id, t.id).then((ok) => {
+ flash(status, ok ? "Switched." : "Couldn't switch — try again.");
+ repaint();
+ });
+ };
+ card.appendChild(row);
+ }
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Back";
+ cancel.onclick = () => backdrop.remove();
+ foot.appendChild(cancel);
+ card.appendChild(foot);
+}
+
async function showDiscover() {
const backdrop = modal();
const card = backdrop.querySelector(".sheet");
@@ -8729,6 +9072,21 @@ function editProfile() {
const levelNotes = document.createElement("div");
card.appendChild(levelNotes);
loadTiers().then(() => {
+ // A membership link when the space sells any level, or you already
+ // subscribe — the way in to subscribe/change/cancel.
+ if (tiers.some((tt) => tt.price_cents > 0 || tt.status)) {
+ const mrow = document.createElement("div");
+ mrow.style.cssText = "margin-top:12px";
+ const mbtn = document.createElement("button");
+ mbtn.className = "linkish";
+ mbtn.textContent = "Manage membership";
+ mbtn.onclick = () => {
+ backdrop.remove();
+ showMembership();
+ };
+ mrow.appendChild(mbtn);
+ card.appendChild(mrow);
+ }
const mine = tiers.filter((tt) => (tt.members || []).includes(myUserId));
if (!mine.length) return;
levelLabel.style.display = "";
diff --git a/web/static/style.css b/web/static/style.css
index 46f551e..dd9af08 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2052,6 +2052,29 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
color: var(--accent-ink); background: var(--accent);
border-radius: 999px; padding: 5px 10px;
}
+
+/* Membership sheet (paid access levels). Quiet cards in the app's own
+ vocabulary; the price is the one emphasized number, one accent, and the
+ state line ("Renews …" / "Access ends …") is the reassuring signature —
+ always dated, never color alone. */
+.plan-card {
+ border: 1px solid var(--border); border-radius: 14px;
+ padding: 14px 16px; margin: 10px 0; display: flex; flex-direction: column; gap: 8px;
+}
+.plan-card.current { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
+.plan-head { display: flex; align-items: baseline; gap: 10px; }
+.plan-name { font-size: 16px; font-weight: 650; color: var(--ink); flex: 1; min-width: 0; }
+.plan-price { font-size: 15px; font-weight: 700; color: var(--ink); white-space: nowrap; }
+.plan-price .per { font-size: 12px; font-weight: 500; color: var(--muted); }
+/* The dated status line — the one thing a member always reads. Icon + words,
+ so a state is never carried by colour alone (WCAG 2.2 AA). */
+.plan-state { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
+.plan-state.warn { color: light-dark(#9a6a00, #f0b84a); } /* payment-failed, still icon+words */
+.plan-state .dot { width: 7px; height: 7px; border-radius: 999px; background: currentColor; flex: none; }
+.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
+.plan-actions .pill-btn { font-size: 13px; }
+.membership-note { font-size: 13px; color: var(--muted); margin: 4px 0 10px; }
+.membership-sec { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 16px 0 2px; }
.thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; }
.thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }