eleven / messenger Public

server+web: lifetime access levels — pay once, in forever (#1290)

* server+web: lifetime access levels — pay once, in forever

Tiers that are not subscriptions (docs/managed-payments.md § Lifetime, to be
added on the doc branch): a level's interval can be 'once' — checkout runs
mode=payment on both providers, with the commission applied as a fixed
amount (BYO: application_fee_amount at the flat rate; managed:
transfer_data[amount] from the chosen PWYW commission, rounded in the
admin's favour). The grant has no lifecycle: a lifetime row is exempt from
the grace sweep, the silent-lapse backstop, and the repair pass (which
would otherwise fetch a payment intent as a subscription, get not-found,
and revoke a valid purchase). charge.refunded / charge.dispute.created
un-buy it immediately, and a refund tombstone (revoked_payments) stops
redelivered grants from re-buying a refunded level — revocation deletes
the row and its ordering watermark, so the tombstone is the no-resurrect
guard subscriptions get from SubState resolution. Floors: lifetime prices
start at 11.00. Member verbs (cancel/resume/change/portal) answer honestly
on lifetime rows; the membership card shows 'Yours for keeps' with no
billing controls.

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

* server+web: BYO commission 10% → 5.5% — the suggested rate of the new ladder

The bring-your-own-Stripe rail now charges 5.5% (on top of the admin's own
Stripe fees), matching the pay-what-you-want suggestion in
docs/managed-payments.md; PWYW config replaces the constant in phase 3.
feePermille mirrors the string for integer money math, pinned by
TestOnceFeeMatchesPercent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdvi8SBfSxzKpUYcdwXnjM

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 37f0601b0138d324029c9fcc0a5c74e0d8bc8bbe parent cd48e0a
10 files changed, +645 −57
  • changelog/2026-07-22-lifetime-access.md +5 −0
  • changelog/2026-07-27-commission-5-5.md +3 −0
  • db.go +45 −9
  • subscriptions.go +111 −17
  • subscriptions_handlers.go +49 −3
  • subscriptions_lifetime_test.go +344 −0
  • subscriptions_platform.go +73 −19
  • web/static/apps/admin.js +2 −2
  • web/static/billingui.js +3 −2
  • web/static/newchat.js +10 −5
diff --git a/changelog/2026-07-22-lifetime-access.md b/changelog/2026-07-22-lifetime-access.md
new file mode 100644
index 0000000..1d0c036
--- /dev/null
+++ b/changelog/2026-07-22-lifetime-access.md
@@ -0,0 +1,5 @@
+🤖 Access levels can now be sold as a single payment: pay once, and the level
+is yours for as long as the space exists — no renewals, nothing to cancel.
+Space admins choose per level: a monthly or yearly subscription, or a one-time
+price (from €11). Refunds work the honest way in both directions: refund a
+purchase and the access goes with it.
diff --git a/changelog/2026-07-27-commission-5-5.md b/changelog/2026-07-27-commission-5-5.md
new file mode 100644
index 0000000..375a43b
--- /dev/null
+++ b/changelog/2026-07-27-commission-5-5.md
@@ -0,0 +1,3 @@
+🤖 Our commission on paid memberships dropped from 10% to 5.5%. If your space
+charges for access levels through your own Stripe account, more of every
+subscription — and every lifetime purchase — now stays with you.
diff --git a/db.go b/db.go
index 3e92bee..3347165 100644
--- a/db.go
+++ b/db.go
@@ -462,6 +462,16 @@ CREATE TABLE IF NOT EXISTS tier_keys (
wrapped TEXT NOT NULL,
PRIMARY KEY (tier_id, user_id)
);
+-- refunded/disputed lifetime purchases (payment intent ids). A refund deletes
+-- the membership row — and with it the per-sub ordering watermark — so this
+-- tombstone is what stops a REDELIVERED grant (Stripe is at-least-once) from
+-- silently re-buying the level. Subscriptions don't need it: their redelivered
+-- receipts resolve against provider truth (SubState); a payment intent has no
+-- equivalent living state to ask about.
+CREATE TABLE IF NOT EXISTS revoked_payments (
+ payment_intent TEXT PRIMARY KEY,
+ revoked_at INTEGER NOT NULL
+);
-- per-user "closed" chats: a thread hidden from that user's list (synced across
-- their devices). Just a marker — no content. Cleared for everyone on new
-- activity (see insertMessage) so a closed chat returns when it's messaged.
@@ -923,6 +933,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_one_space_thread ON threads(kind) WHERE ki
// 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",
+ // Lifetime (one-time) purchases: a paid membership with no lifecycle —
+ // pay once, keep access. The flag is what exempts the row from every
+ // renewal-shaped sweep (grace, silent-lapse backstop, repair); the only
+ // way out is a refund/chargeback event or an admin removal.
+ "ALTER TABLE user_tiers ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0",
// Delete tombstones (#581): unsending hard-deletes the row (the
// ciphertext really goes) and inserts a fresh empty row whose
// tombstone_of names the deleted id. The fresh autoincrement id lands
@@ -2420,7 +2435,7 @@ type Tier struct {
// 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'
+ Interval string `json:"interval,omitempty"` // 'month' | 'year' | 'once' (lifetime)
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
@@ -2428,6 +2443,9 @@ type Tier struct {
Status string `json:"status,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
GraceDeadline int64 `json:"grace_deadline,omitempty"`
+ // Lifetime: MY membership here is a one-time purchase (never renews,
+ // nothing to cancel) — the UI drops every billing control off it.
+ Lifetime bool `json:"lifetime,omitempty"`
}
func (db *DB) createTier(name string) (string, error) {
@@ -2545,6 +2563,22 @@ type Entitlement struct {
ExtCustomer string
EventTS int64 // provider event `created` last folded, for ordering
Source string // how the membership was assigned: admin | invite | stripe
+ Lifetime bool // a one-time purchase: no lifecycle, never swept
+}
+
+// markPaymentRevoked tombstones a refunded/disputed lifetime purchase; a
+// redelivered grant for it is dead on arrival (applyWebhookEvent).
+func (db *DB) markPaymentRevoked(paymentIntent string) error {
+ _, err := db.sql.Exec(
+ "INSERT INTO revoked_payments(payment_intent, revoked_at) VALUES(?, ?) ON CONFLICT DO NOTHING",
+ paymentIntent, time.Now().Unix())
+ return err
+}
+
+func (db *DB) isPaymentRevoked(paymentIntent string) bool {
+ var n int
+ _ = db.sql.QueryRow("SELECT COUNT(*) FROM revoked_payments WHERE payment_intent = ?", paymentIntent).Scan(&n)
+ return n > 0
}
// setEntitlement upserts a member's paid membership. It ensures the user_tiers
@@ -2574,11 +2608,11 @@ func (db *DB) setEntitlement(e Entitlement) error {
// sub's watermark is meaningless — keep the incoming event's stamp, or the
// moved sub's later, lower-stamped events would be dropped as stale.
if _, err := tx.Exec(
- `UPDATE user_tiers SET status=?, paid_until=?, grace_deadline=?,
+ `UPDATE user_tiers SET status=?, paid_until=?, grace_deadline=?, lifetime=?,
ext_event_ts = CASE WHEN COALESCE(ext_sub,'') = COALESCE(?,'') THEN MAX(ext_event_ts, ?) ELSE ? END,
ext_sub=?, ext_customer=?
WHERE tier_id=? AND user_id=?`,
- e.Status, e.PaidUntil, e.GraceDeadline,
+ e.Status, e.PaidUntil, e.GraceDeadline, e.Lifetime,
nullable(e.ExtSub), e.EventTS, e.EventTS,
nullable(e.ExtSub), nullable(e.ExtCustomer), e.TierID, e.UserID); err != nil {
return err
@@ -2627,8 +2661,8 @@ 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)
+ "SELECT tier_id, user_id, COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), COALESCE(ext_event_ts,0), COALESCE(lifetime,0) FROM user_tiers WHERE ext_sub = ?",
+ extSub).Scan(&e.TierID, &e.UserID, &status, &e.PaidUntil, &e.GraceDeadline, &e.EventTS, &e.Lifetime)
if err != nil {
return Entitlement{}, false
}
@@ -2644,8 +2678,8 @@ 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)
+ "SELECT COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), ext_sub, ext_customer, COALESCE(ext_event_ts,0), COALESCE(lifetime,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, &e.Lifetime)
if err != nil {
return Entitlement{}, false
}
@@ -2694,6 +2728,7 @@ func (db *DB) entitlementsNeedingRepair(limit int) ([]Entitlement, error) {
rows, err := db.sql.Query(
`SELECT tier_id, user_id, ext_sub FROM user_tiers
WHERE ext_sub IS NOT NULL AND ext_sub != '' AND status = 'active' AND paid_until = 0
+ AND COALESCE(lifetime, 0) = 0
LIMIT ?`, limit)
if err != nil {
return nil, err
@@ -2849,7 +2884,8 @@ func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
CASE WHEN m.user_id IS NULL THEN 0 ELSE 1 END,
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)
+ COALESCE(m.status, ''), COALESCE(m.paid_until, 0), COALESCE(m.grace_deadline, 0),
+ COALESCE(m.lifetime, 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 = ?
@@ -2865,7 +2901,7 @@ func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
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,
- &t.PriceCents, &t.Currency, &t.Interval, &t.Status, &t.PaidUntil, &t.GraceDeadline); err != nil {
+ &t.PriceCents, &t.Currency, &t.Interval, &t.Status, &t.PaidUntil, &t.GraceDeadline, &t.Lifetime); err != nil {
return nil, err
}
t.Mine = mine == 1
diff --git a/subscriptions.go b/subscriptions.go
index 51441f5..4378208 100644
--- a/subscriptions.go
+++ b/subscriptions.go
@@ -8,7 +8,7 @@ package main
// 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
+// admin is merchant of record); the platform takes a 5.5% application fee. The
// payment backend is a seam (PayProvider); stripeConnect is one implementation.
// NB: docs/managed-payments.md (2026-07-22) redesigns this surface — managed
// payouts become the default provider and this BYO path becomes a
@@ -42,9 +42,22 @@ 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"
+// The platform commission on member subscriptions on the bring-your-own-Stripe
+// rail — the suggested rate (5.5%), charged on top of the admin's own Stripe
+// fees. Pay-what-you-want configuration replaces this constant later
+// (docs/managed-payments.md phase 3); until then everyone pays the suggestion.
+const applicationFeePercent = "5.5"
+
+// feePermille mirrors applicationFeePercent for integer money math — keep the
+// two in step (pinned by TestOnceFeeMatchesPercent).
+const feePermille = 55
+
+// onceFeeMinor is the platform fee on a lifetime purchase, in minor units —
+// payment mode has no application_fee_percent, so the same rate is applied as
+// a fixed amount at mint time. Integer floor: rounding favours the admin.
+func onceFeeMinor(amountMinor int) int {
+ return amountMinor * feePermille / 1000
+}
// SubEvent is a payment provider's subscription state, normalized. A provider's
// ParseWebhook produces these; applySubEvent is the only consumer, so adding a
@@ -66,6 +79,11 @@ type SubEvent struct {
Checkout bool // a checkout receipt: a creation signal carrying NO lifecycle
// data (no period end, no cancel state) — must never advance the
// per-sub ordering watermark (see applySubEvent)
+ // Lifetime: a one-time purchase event (docs/managed-payments.md § Lifetime).
+ // A grant (AllowCreate) IS the full truth — no SubState resolution, no
+ // lifecycle follows; Deleted on a Lifetime event is a refund/chargeback,
+ // which revokes immediately. ExtSub carries the payment intent id.
+ Lifetime bool
}
// CheckoutReq is what a member's subscribe needs from the provider.
@@ -75,6 +93,11 @@ type CheckoutReq struct {
UserRef string // our uid — rides as subscription metadata for the webhook
SuccessURL string
CancelURL string
+ // Once marks a lifetime purchase: mode=payment instead of subscription.
+ // Payment mode has no percent-based splits, so AmountMinor (the level's
+ // price) lets the provider compute its fixed fee/transfer server-side.
+ Once bool
+ AmountMinor int
}
// PayProvider is the payment backend seam. The no-op provider (Configured()
@@ -179,6 +202,19 @@ func newPayProvider(db *DB) PayProvider {
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.Lifetime && (ev.Deleted || ev.Status == "canceled"):
+ // A refund/chargeback un-buys a lifetime purchase: revoke immediately —
+ // grace windows exist to survive a failing card, and nothing is failing.
+ e.Status = "canceled"
+ e.Lifetime = true
+ e.GraceDeadline = now
+ active = false
+ case ev.Lifetime:
+ // Paid once, in forever: no period end, no grace clock. The lifetime
+ // flag exempts the row from every renewal-shaped sweep (db.go).
+ e.Status = "active"
+ e.Lifetime = true
+ active = true
case ev.Deleted || ev.Status == "canceled":
e.Status = "canceled"
e.PaidUntil = ev.CurrentPeriodEnd
@@ -309,7 +345,7 @@ func (s *Server) startBillingSweep() {
// ---- 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
+// header), with the platform's application fee on top. 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.
@@ -364,18 +400,30 @@ func (p *stripeConnect) post(ctx context.Context, path string, form url.Values)
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)
+ if r.Once {
+ // Lifetime purchase: one payment, no subscription. Payment mode takes
+ // the platform fee as a fixed amount, computed from the same percent.
+ // The payment intent carries the routing metadata too, so a later
+ // refund/chargeback (charge events reference the intent, not the
+ // session) can find its way back to this membership.
+ form.Set("mode", "payment")
+ form.Set("payment_intent_data[application_fee_amount]", strconv.Itoa(onceFeeMinor(r.AmountMinor)))
+ form.Set("payment_intent_data[metadata][user_ref]", r.UserRef)
+ form.Set("payment_intent_data[metadata][tier_ref]", r.TierID)
+ } else {
+ form.Set("mode", "subscription")
+ // The 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)
+ }
body, err := p.post(ctx, "/v1/checkout/sessions", form)
if err != nil {
return "", err
@@ -503,12 +551,31 @@ func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error
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"`
+ Mode string `json:"mode"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ PaymentIntent string `json:"payment_intent"`
+ Metadata map[string]string `json:"metadata"`
}
- if err := json.Unmarshal(ev.Data.Object, &o); err != nil || o.Mode != "subscription" {
+ if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
+ return nil, nil
+ }
+ // A payment-mode session with tier routing is a LIFETIME purchase —
+ // the one event is the whole truth (no lifecycle follows), so it is
+ // not a receipt to resolve (Checkout=false) and its id is the payment
+ // intent. Payment-mode sessions without tier_ref are someone else's
+ // commerce; dropped.
+ if o.Mode == "payment" {
+ if o.Metadata["tier_ref"] == "" || o.PaymentIntent == "" {
+ return nil, nil
+ }
+ return []SubEvent{{
+ ExtSub: o.PaymentIntent, ExtCustomer: o.Customer,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ Status: "active", Created: ev.Created, AllowCreate: true, Lifetime: true,
+ }}, nil
+ }
+ if o.Mode != "subscription" {
return nil, nil
}
return []SubEvent{{
@@ -516,6 +583,8 @@ func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error
UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true,
}}, nil
+ case ev.Type == "charge.refunded" || ev.Type == "charge.dispute.created":
+ return lifetimeRevokeEvent(ev.Data.Object, ev.Created)
case strings.HasPrefix(ev.Type, "customer.subscription."):
var o subObject
if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
@@ -530,6 +599,29 @@ func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error
return nil, nil
}
+// lifetimeRevokeEvent normalizes a refund/dispute into the event that un-buys
+// a lifetime purchase. Both charge.refunded and charge.dispute.created carry
+// the payment intent plus the metadata stamped at checkout; anything without
+// tier routing is other commerce on the same account (subscription refunds
+// resolve through their own lifecycle) — dropped. Shared by both providers.
+func lifetimeRevokeEvent(raw json.RawMessage, created int64) ([]SubEvent, error) {
+ var o struct {
+ PaymentIntent string `json:"payment_intent"`
+ Metadata map[string]string `json:"metadata"`
+ }
+ if err := json.Unmarshal(raw, &o); err != nil {
+ return nil, nil
+ }
+ if o.PaymentIntent == "" || o.Metadata["tier_ref"] == "" {
+ return nil, nil
+ }
+ return []SubEvent{{
+ ExtSub: o.PaymentIntent,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ Status: "canceled", Created: created, Deleted: true, Lifetime: true,
+ }}, nil
+}
+
// subObject is the slice of a provider subscription we act on — the same
// shape arrives in webhook payloads and from a SubState fetch.
type subObject struct {
@@ -611,7 +703,9 @@ func (p *stripeConnect) CreatePrice(ctx context.Context, name string, cents int,
form := url.Values{}
form.Set("unit_amount", strconv.Itoa(cents))
form.Set("currency", strings.ToLower(currency))
- form.Set("recurring[interval]", interval)
+ if interval != "once" { // a lifetime level's Price is one-time
+ form.Set("recurring[interval]", interval)
+ }
form.Set("product_data[name]", name)
body, err := p.post(ctx, "/v1/prices", form)
if err != nil {
diff --git a/subscriptions_handlers.go b/subscriptions_handlers.go
index d4bb34c..cd7ef96 100644
--- a/subscriptions_handlers.go
+++ b/subscriptions_handlers.go
@@ -129,11 +129,20 @@ func (s *Server) postTierPrice(w http.ResponseWriter, r *http.Request) {
// (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") ||
+ if body.Cents < 0 || body.Cents > 100_000_00 ||
+ (body.Interval != "month" && body.Interval != "year" && body.Interval != "once") ||
!supportedCurrency(body.Currency) {
httpError(w, http.StatusBadRequest, "bad price")
return
}
+ // Lifetime levels floor at 11.00 (docs/managed-payments.md § Lifetime):
+ // one payment carries the fixed card fee and the forever promise — priced
+ // too low it's a fee sink and a support burden. Subscriptions' floors
+ // arrive with the phase-3 repricing.
+ if body.Interval == "once" && body.Cents < 1100 {
+ httpError(w, http.StatusBadRequest, "lifetime access starts at 11.00")
+ 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)
@@ -170,7 +179,7 @@ func (s *Server) postTierSubscribe(w http.ResponseWriter, r *http.Request) {
return
}
tierID := r.PathValue("id")
- _, _, _, ref, sellable := s.db.tierPrice(tierID)
+ cents, _, interval, ref, sellable := s.db.tierPrice(tierID)
if !sellable || ref == "" {
httpError(w, http.StatusBadRequest, "this level isn't for sale")
return
@@ -192,6 +201,7 @@ func (s *Server) postTierSubscribe(w http.ResponseWriter, r *http.Request) {
origin := s.origin(r)
u, err := s.pay.Checkout(r.Context(), CheckoutReq{
TierID: tierID, PriceRef: ref, UserRef: uid,
+ Once: interval == "once", AmountMinor: cents,
SuccessURL: origin + "/?subscribed=1",
CancelURL: origin + "/",
})
@@ -227,11 +237,21 @@ func (s *Server) postTierChange(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "you don't have a subscription to change")
return
}
- _, _, _, ref, sellable := s.db.tierPrice(body.ToTier)
+ if ent.Lifetime {
+ httpError(w, http.StatusBadRequest, "this is a one-time purchase — buy the other level outright instead of switching")
+ return
+ }
+ _, _, toInterval, ref, sellable := s.db.tierPrice(body.ToTier)
if !sellable || ref == "" {
httpError(w, http.StatusBadRequest, "that level isn't for sale")
return
}
+ // A subscription can't be switched ONTO a one-time price (SwitchLevel
+ // updates the sub's item; a non-recurring price can't be a sub item).
+ if toInterval == "once" {
+ httpError(w, http.StatusBadRequest, "that level is a one-time purchase — buy it outright instead of switching")
+ 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
@@ -262,6 +282,10 @@ func (s *Server) postTierCancelSub(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "no subscription to cancel")
return
}
+ if ent.Lifetime {
+ httpError(w, http.StatusBadRequest, "this is a one-time purchase — nothing renews, nothing to cancel")
+ return
+ }
if err := s.pay.Cancel(r.Context(), ent.ExtSub); err != nil {
httpError(w, http.StatusBadGateway, "couldn't cancel — try again")
return
@@ -294,6 +318,10 @@ func (s *Server) postTierResume(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "no subscription to resume")
return
}
+ if ent.Lifetime {
+ httpError(w, http.StatusBadRequest, "this is a one-time purchase — it never ends, so there's nothing to resume")
+ return
+ }
if err := s.pay.Resume(r.Context(), ent.ExtSub); err != nil {
httpError(w, http.StatusBadGateway, "couldn't resume — try again")
return
@@ -324,6 +352,10 @@ func (s *Server) postTierPortal(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "no subscription to manage")
return
}
+ if ent.Lifetime {
+ httpError(w, http.StatusBadRequest, "this is a one-time purchase — there's no ongoing billing 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")
@@ -410,6 +442,20 @@ func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64)
// Still Checkout=true: fetched state is out-of-band truth, not an
// ordered event — it must not advance the per-sub watermark.
}
+ // Lifetime purchases have no provider state to resolve, so their
+ // no-resurrect guard is the refund tombstone: a refund/chargeback marks
+ // the payment intent dead before revoking (revocation deletes the row and
+ // its watermark), and any grant for a dead intent — a redelivery, however
+ // late — is dropped.
+ if ev.Lifetime {
+ if ev.Deleted {
+ if err := s.db.markPaymentRevoked(ev.ExtSub); err != nil {
+ return err
+ }
+ } else if s.db.isPaymentRevoked(ev.ExtSub) {
+ return nil
+ }
+ }
tierID, userID := ev.TierRef, ev.UserRef
bySub, haveBySub := s.db.entitlementBySub(ev.ExtSub)
if tierID == "" || userID == "" {
diff --git a/subscriptions_lifetime_test.go b/subscriptions_lifetime_test.go
new file mode 100644
index 0000000..ccf11a9
--- /dev/null
+++ b/subscriptions_lifetime_test.go
@@ -0,0 +1,344 @@
+package main
+
+// 🤖 Lifetime access levels (docs/managed-payments.md § Lifetime): pay once,
+// in forever. These pin the four load-bearing properties: the grant has no
+// lifecycle (no sweep, no repair pass may ever touch it), a refund/chargeback
+// un-buys it immediately, both providers mint payment-mode checkouts with the
+// commission applied as a fixed amount, and the renewal-shaped member
+// endpoints (cancel/resume/change/portal) answer honestly instead of
+// confusing Stripe with a payment-intent id.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// countingPay is a configured provider that counts SubState fetches — the
+// repair-pass skip is the property under test, so the count is the assert.
+type countingPay struct {
+ noPay
+ subStateCalls int
+}
+
+func (c *countingPay) Configured() bool { return true }
+func (c *countingPay) SubState(context.Context, string) (SubEvent, bool, error) {
+ c.subStateCalls++
+ return SubEvent{}, false, nil
+}
+
+// lifetimeGrant is the event a completed payment-mode checkout produces.
+func lifetimeGrant(uid, tid string, now int64) SubEvent {
+ return SubEvent{
+ ExtSub: "pi_life_1", ExtCustomer: "cus_1", UserRef: uid, TierRef: tid,
+ Status: "active", Created: now, AllowCreate: true, Lifetime: true,
+ }
+}
+
+func TestLifetimeGrantNeverSwept(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Founder", "bea")
+ now := int64(3_000_000)
+
+ if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil {
+ t.Fatal(err)
+ }
+ if !srv.db.tierMember(tid, uid) {
+ t.Fatal("member not assigned after lifetime purchase")
+ }
+ e, ok := srv.db.memberEntitlement(tid, uid)
+ if !ok || !e.Lifetime || e.Status != "active" || e.ExtSub != "pi_life_1" {
+ t.Fatalf("entitlement = %+v ok=%v, want active lifetime on pi_life_1", e, ok)
+ }
+
+ // A decade of sweeps: the grace clause can't match (no deadline), the
+ // silent-lapse backstop can't match (no paid_until), and the repair pass
+ // must SKIP the row — there is no subscription to re-fetch, and a
+ // not-found answer would revoke a valid purchase.
+ fake := &countingPay{}
+ srv.pay = fake
+ tenYears := now + 10*365*24*3600
+ if n := srv.runBillingSweep(tenYears); n != 0 {
+ t.Fatalf("sweep revoked %d membership(s) — a lifetime purchase expired", n)
+ }
+ if !srv.db.tierMember(tid, uid) {
+ t.Fatal("lifetime membership gone after sweeps")
+ }
+ if fake.subStateCalls != 0 {
+ t.Fatalf("repair pass fetched provider state %d time(s) for a lifetime row", fake.subStateCalls)
+ }
+
+ // Redelivery of the same grant is a no-op, not a duplicate.
+ if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil {
+ t.Fatal(err)
+ }
+ if !srv.db.tierMember(tid, uid) {
+ t.Fatal("redelivered grant broke the membership")
+ }
+}
+
+func TestLifetimeRefundRevokes(t *testing.T) {
+ _, srv := newTestServer(t)
+ tid, uid := seedTierAndMember(t, srv, "Founder", "bea")
+ now := int64(3_000_000)
+ if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil {
+ t.Fatal(err)
+ }
+
+ // The refund/chargeback event: revoke is immediate — grace windows exist
+ // to survive a failing card, and nothing here is failing.
+ refund := SubEvent{
+ ExtSub: "pi_life_1", UserRef: uid, TierRef: tid,
+ Status: "canceled", Created: now + 100, Deleted: true, Lifetime: true,
+ }
+ if err := srv.applyWebhookEvent(context.Background(), refund, now+100); err != nil {
+ t.Fatal(err)
+ }
+ if srv.db.tierMember(tid, uid) {
+ t.Fatal("membership survived a refund")
+ }
+ // And the no-resurrect property holds: a redelivered grant older than the
+ // refund must not re-buy the level.
+ if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now+200); err != nil {
+ t.Fatal(err)
+ }
+ if srv.db.tierMember(tid, uid) {
+ t.Fatal("stale redelivered grant resurrected a refunded purchase")
+ }
+}
+
+// connectTestProvider is a stripeConnect against a captured fake Stripe.
+func connectTestProvider(t *testing.T, srv *Server) (*stripeConnect, *map[string][]string) {
+ t.Helper()
+ stripe, captured, _ := fakePlatformStripe(t)
+ if err := srv.db.setConfig("stripe_connect_account", "acct_admin"); err != nil {
+ t.Fatal(err)
+ }
+ p := &stripeConnect{db: srv.db, key: "sk", webhookSecret: "whsec_connect", apiBase: stripe.URL, client: http.DefaultClient}
+ return p, (*map[string][]string)(captured)
+}
+
+func TestLifetimeCheckoutForms(t *testing.T) {
+ _, srv := newTestServer(t)
+
+ // BYO: mode=payment, the fee as a fixed amount (5.5% of the price), the
+ // routing metadata mirrored onto the payment intent for refund routing,
+ // and nothing subscription-shaped.
+ pc, capturedC := connectTestProvider(t, srv)
+ if _, err := pc.Checkout(context.Background(), CheckoutReq{
+ TierID: "t1", PriceRef: "price_once", UserRef: "u1",
+ Once: true, AmountMinor: 11000,
+ SuccessURL: "https://s/ok", CancelURL: "https://s/no",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ fc := *capturedC
+ if got := fc["mode"]; len(got) != 1 || got[0] != "payment" {
+ t.Errorf("BYO mode = %v", got)
+ }
+ if got := fc["payment_intent_data[application_fee_amount]"]; len(got) != 1 || got[0] != "605" {
+ t.Errorf("BYO fee = %v, want 605 (5.5%% of 11000)", got)
+ }
+ if got := fc["payment_intent_data[metadata][tier_ref]"]; len(got) != 1 || got[0] != "t1" {
+ t.Errorf("BYO intent metadata = %v", fc)
+ }
+ for k := range fc {
+ if strings.Contains(k, "subscription_data") {
+ t.Errorf("BYO once checkout sent %s", k)
+ }
+ }
+
+ // Managed: mode=payment, the admin's share as a fixed transfer computed
+ // from the chosen commission (8% of 11000 → 10120 transfers).
+ if err := srv.db.setConfig("stripe_express_account", "acct_express"); err != nil {
+ t.Fatal(err)
+ }
+ if err := srv.db.setConfig("managed_commission_pct", "8"); err != nil {
+ t.Fatal(err)
+ }
+ stripe, capturedP, _ := fakePlatformStripe(t)
+ pp := &stripePlatform{db: srv.db, key: "sk", webhookSecret: "whsec_platform", apiBase: stripe.URL, client: http.DefaultClient}
+ if _, err := pp.Checkout(context.Background(), CheckoutReq{
+ TierID: "t1", PriceRef: "price_once", UserRef: "u1",
+ Once: true, AmountMinor: 11000,
+ SuccessURL: "https://s/ok", CancelURL: "https://s/no",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ fp := *capturedP
+ if got := fp.Get("payment_intent_data[transfer_data][amount]"); got != "10120" {
+ t.Errorf("managed transfer = %q, want 10120 (92%% of 11000)", got)
+ }
+ if got := fp.Get("payment_intent_data[transfer_data][destination]"); got != "acct_express" {
+ t.Errorf("managed destination = %q", got)
+ }
+ for k := range fp {
+ if strings.Contains(k, "subscription_data") || strings.Contains(k, "application_fee") {
+ t.Errorf("managed once checkout sent %s", k)
+ }
+ }
+}
+
+func TestLifetimeWebhookParsing(t *testing.T) {
+ _, srv := newTestServer(t)
+ if err := srv.db.setConfig("stripe_express_account", "acct_ours"); err != nil {
+ t.Fatal(err)
+ }
+ p := &stripePlatform{db: srv.db, key: "sk", webhookSecret: "whsec_platform", apiBase: "http://unused", client: http.DefaultClient}
+
+ // A payment-mode session with our binding → a lifetime grant on the
+ // payment intent id.
+ session := map[string]any{
+ "mode": "payment", "customer": "cus1", "payment_intent": "pi_1",
+ "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"},
+ }
+ body, sig := platformEvent(t, "checkout.session.completed", session)
+ evs, err := p.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 {
+ t.Fatalf("grant: %+v, %v", evs, err)
+ }
+ if e := evs[0]; !e.Lifetime || !e.AllowCreate || e.Checkout || e.ExtSub != "pi_1" {
+ t.Errorf("grant = %+v, want lifetime AllowCreate on pi_1, never a receipt", e)
+ }
+
+ // A donate/enterprise-shaped payment session (no binding): dropped.
+ stray := map[string]any{"mode": "payment", "payment_intent": "pi_x", "metadata": map[string]any{"donation": "1"}}
+ body, sig = platformEvent(t, "checkout.session.completed", stray)
+ if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil {
+ t.Fatalf("stray payment session must drop: %+v, %v", evs, err)
+ }
+
+ // charge.refunded with our stamped metadata → the un-buy event.
+ refund := map[string]any{
+ "payment_intent": "pi_1",
+ "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"},
+ }
+ body, sig = platformEvent(t, "charge.refunded", refund)
+ evs, err = p.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 || !evs[0].Deleted || !evs[0].Lifetime || evs[0].ExtSub != "pi_1" {
+ t.Fatalf("refund: %+v, %v", evs, err)
+ }
+ // A refund on some other space's charge: dropped.
+ foreign := map[string]any{
+ "payment_intent": "pi_2",
+ "metadata": map[string]any{"tier_ref": "t9", "space_ref": "acct_theirs"},
+ }
+ body, sig = platformEvent(t, "charge.refunded", foreign)
+ if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil {
+ t.Fatalf("foreign refund must drop: %+v, %v", evs, err)
+ }
+
+ // The BYO provider: same shapes, bound by connected account instead.
+ if err := srv.db.setConfig("stripe_connect_account", "acct_admin"); err != nil {
+ t.Fatal(err)
+ }
+ pc := &stripeConnect{db: srv.db, key: "sk", webhookSecret: "whsec_connect", apiBase: "http://unused", client: http.DefaultClient}
+ connectEvent := func(evType string, object map[string]any) ([]byte, string) {
+ raw, _ := json.Marshal(map[string]any{
+ "type": evType, "account": "acct_admin", "created": 1000,
+ "data": map[string]any{"object": object},
+ })
+ return raw, stripeSign(raw, "whsec_connect", time.Now())
+ }
+ sess := map[string]any{
+ "mode": "payment", "customer": "cus1", "payment_intent": "pi_c1",
+ "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1"},
+ }
+ body, sig = connectEvent("checkout.session.completed", sess)
+ evs, err = pc.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 || !evs[0].Lifetime || evs[0].ExtSub != "pi_c1" {
+ t.Fatalf("BYO grant: %+v, %v", evs, err)
+ }
+ body, sig = connectEvent("charge.refunded", map[string]any{
+ "payment_intent": "pi_c1", "metadata": map[string]any{"tier_ref": "t1", "user_ref": "u1"},
+ })
+ evs, err = pc.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 || !evs[0].Deleted || !evs[0].Lifetime {
+ t.Fatalf("BYO refund: %+v, %v", evs, err)
+ }
+}
+
+func TestLifetimePriceFloorAndMemberGuards(t *testing.T) {
+ ts, srv := newTestServer(t)
+ srv.pay = &fakePay{checkoutURL: "https://pay.example/checkout"}
+
+ mkUser := func(label string) (string, string) {
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatal(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.Fatal(err)
+ }
+ return tk, u
+ }
+ admin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatal(err)
+ }
+ tok, uid := mkUser("Bea")
+ tid, err := srv.db.createTier("Founder")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ post := func(path string, body map[string]any) int {
+ raw, _ := json.Marshal(body)
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(string(raw)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ // The €11 floor on lifetime pricing; a valid once price sticks.
+ if code := post(fmt.Sprintf("/api/tiers/%s/price", tid), map[string]any{
+ "token": admin, "cents": 500, "currency": "eur", "interval": "once"}); code != http.StatusBadRequest {
+ t.Errorf("€5 lifetime accepted: %d", code)
+ }
+ if code := post(fmt.Sprintf("/api/tiers/%s/price", tid), map[string]any{
+ "token": admin, "cents": 11000, "currency": "eur", "interval": "once", "provider_ref": "price_once"}); code != http.StatusOK {
+ t.Errorf("€110 lifetime rejected: %d", code)
+ }
+
+ // A lifetime holder gets honest answers from the renewal-shaped verbs.
+ now := time.Now().Unix()
+ if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"cancel-subscription", "resume", "portal"} {
+ if code := post(fmt.Sprintf("/api/tiers/%s/%s", tid, path), map[string]any{"token": tok}); code != http.StatusBadRequest {
+ t.Errorf("%s on a lifetime purchase = %d, want 400", path, code)
+ }
+ }
+}
+
+// TestOnceFeeMatchesPercent pins feePermille to applicationFeePercent — the
+// string feeds Stripe's subscription form, the integer feeds payment-mode
+// money math, and they must describe the same rate.
+func TestOnceFeeMatchesPercent(t *testing.T) {
+ pct, err := strconv.ParseFloat(applicationFeePercent, 64)
+ if err != nil {
+ t.Fatalf("applicationFeePercent %q: %v", applicationFeePercent, err)
+ }
+ if got := float64(feePermille) / 10; got != pct {
+ t.Errorf("feePermille %d = %.2f%%, applicationFeePercent = %s", feePermille, got, applicationFeePercent)
+ }
+ // Floor rounding favours the admin: 5.5% of 999 is 54.945 → 54.
+ if got := onceFeeMinor(999); got != 54 {
+ t.Errorf("onceFeeMinor(999) = %d, want 54", got)
+ }
+}
diff --git a/subscriptions_platform.go b/subscriptions_platform.go
index 735f99d..4654648 100644
--- a/subscriptions_platform.go
+++ b/subscriptions_platform.go
@@ -38,17 +38,30 @@ const (
managedKeepMinPct = 5.5
)
-// managedTransferPercent resolves this space's chosen commission to the
-// amount_percent Stripe gets: 100 − clamp(choice, 5.5, 11), so "89"…"94.5".
-// Unset or unparseable → the suggested 11 ("89") — never a silent freebie.
-func (p *stripePlatform) managedTransferPercent() string {
+// managedKeepPct is this space's chosen commission: clamp(choice, 5.5, 11).
+// Unset or unparseable → the suggested 11 — never a silent freebie.
+func (p *stripePlatform) managedKeepPct() float64 {
keep := managedKeepDefaultPct
if v, _, _ := p.db.getConfig("managed_commission_pct"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
keep = min(max(f, managedKeepMinPct), managedKeepDefaultPct)
}
}
- return strconv.FormatFloat(100-keep, 'f', -1, 64)
+ return keep
+}
+
+// managedTransferPercent is the commission's complement as Stripe's
+// amount_percent: "89"…"94.5".
+func (p *stripePlatform) managedTransferPercent() string {
+ return strconv.FormatFloat(100-p.managedKeepPct(), 'f', -1, 64)
+}
+
+// onceTransferMinor is the admin's share of a lifetime purchase in minor
+// units — payment mode has no amount_percent, so the same commission is
+// applied as a fixed transfer, rounded in the admin's favour.
+func (p *stripePlatform) onceTransferMinor(amountMinor int) int {
+ fee := int(float64(amountMinor) * p.managedKeepPct() / 100)
+ return amountMinor - fee
}
// stripePlatform is the managed-payments provider: same PayProvider seam as
@@ -131,25 +144,40 @@ func (p *stripePlatform) get(ctx context.Context, path string) (map[string]any,
func (p *stripePlatform) Checkout(ctx context.Context, r CheckoutReq) (string, error) {
acct := p.expressAccount()
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 structural split: 89% of every invoice transfers to the space's
- // Express account; the platform's 11 points (and Stripe's fees, out of
- // them) stay behind. No application fee — the transfer IS the split.
- form.Set("subscription_data[transfer_data][destination]", acct)
- form.Set("subscription_data[transfer_data][amount_percent]", p.managedTransferPercent())
// Routing metadata, plus the space binding: platform-account webhooks
// carry every managed space's events, so each instance stamps its own
// Express account id and ParseWebhook drops the rest.
- form.Set("subscription_data[metadata][user_ref]", r.UserRef)
- form.Set("subscription_data[metadata][tier_ref]", r.TierID)
- form.Set("subscription_data[metadata][space_ref]", acct)
form.Set("metadata[user_ref]", r.UserRef)
form.Set("metadata[tier_ref]", r.TierID)
form.Set("metadata[space_ref]", acct)
+ if r.Once {
+ // Lifetime purchase: one payment, structurally split like the
+ // subscription case — but payment mode has no amount_percent, so the
+ // admin's share is a fixed amount from the same chosen commission.
+ // The intent carries the routing metadata too (refund/chargeback
+ // events reference the intent, not the session).
+ form.Set("mode", "payment")
+ form.Set("payment_intent_data[transfer_data][destination]", acct)
+ form.Set("payment_intent_data[transfer_data][amount]", strconv.Itoa(p.onceTransferMinor(r.AmountMinor)))
+ form.Set("payment_intent_data[metadata][user_ref]", r.UserRef)
+ form.Set("payment_intent_data[metadata][tier_ref]", r.TierID)
+ form.Set("payment_intent_data[metadata][space_ref]", acct)
+ } else {
+ form.Set("mode", "subscription")
+ // The structural split: the admin's share of every invoice transfers
+ // to the space's Express account; the platform's points (and Stripe's
+ // fees, out of them) stay behind. No application fee — the transfer
+ // IS the split.
+ form.Set("subscription_data[transfer_data][destination]", acct)
+ form.Set("subscription_data[transfer_data][amount_percent]", p.managedTransferPercent())
+ form.Set("subscription_data[metadata][user_ref]", r.UserRef)
+ form.Set("subscription_data[metadata][tier_ref]", r.TierID)
+ form.Set("subscription_data[metadata][space_ref]", acct)
+ }
body, err := p.post(ctx, "/v1/checkout/sessions", form)
if err != nil {
return "", err
@@ -289,15 +317,31 @@ func (p *stripePlatform) ParseWebhook(sig string, body []byte) ([]SubEvent, erro
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"`
+ Mode string `json:"mode"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ PaymentIntent string `json:"payment_intent"`
+ Metadata map[string]string `json:"metadata"`
}
- if err := json.Unmarshal(ev.Data.Object, &o); err != nil || o.Mode != "subscription" {
+ if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
return nil, nil
}
if o.Metadata["space_ref"] != acct {
+ return nil, nil // another space's sale — or donate/enterprise, which never stamp one
+ }
+ // Payment mode + our binding = a lifetime purchase (see the BYO
+ // provider for the shape's rationale).
+ if o.Mode == "payment" {
+ if o.Metadata["tier_ref"] == "" || o.PaymentIntent == "" {
+ return nil, nil
+ }
+ return []SubEvent{{
+ ExtSub: o.PaymentIntent, ExtCustomer: o.Customer,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ Status: "active", Created: ev.Created, AllowCreate: true, Lifetime: true,
+ }}, nil
+ }
+ if o.Mode != "subscription" {
return nil, nil
}
return []SubEvent{{
@@ -305,6 +349,16 @@ func (p *stripePlatform) ParseWebhook(sig string, body []byte) ([]SubEvent, erro
UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true,
}}, nil
+ case ev.Type == "charge.refunded" || ev.Type == "charge.dispute.created":
+ // The refund/chargeback that un-buys a lifetime purchase. Bind on the
+ // stamped space_ref before the shared normalizer, same as above.
+ var bind struct {
+ Metadata map[string]string `json:"metadata"`
+ }
+ if err := json.Unmarshal(ev.Data.Object, &bind); err != nil || bind.Metadata["space_ref"] != acct {
+ return nil, nil
+ }
+ return lifetimeRevokeEvent(ev.Data.Object, ev.Created)
case strings.HasPrefix(ev.Type, "customer.subscription."):
var o subObject
if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index ecd8e5e..26e9e5c 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -574,7 +574,7 @@ export const app = defineApp({
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${st.account_id ? ` (${st.account_id})` : ""}; we take 10%.`
+ ? `Paid access levels are on — members pay your Stripe account${st.account_id ? ` (${st.account_id})` : ""}; we take 5.5%.`
: "Charge for access levels: connect your Stripe account to start.";
row.appendChild(txt);
// Linking goes through Stripe's own consent page (OAuth) — never a
@@ -711,7 +711,7 @@ export const app = defineApp({
// 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.
+ // platform takes a 5.5% fee.
if (billingReady) {
const priceRow = document.createElement("div");
priceRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap";
diff --git a/web/static/billingui.js b/web/static/billingui.js
index 34d908a..82b7178 100644
--- a/web/static/billingui.js
+++ b/web/static/billingui.js
@@ -131,8 +131,9 @@ export 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 };
+ // 'once' is a lifetime purchase — rendered "· once", never "/ once".
+ const per = t.interval === "once" ? "once" : t.interval === "year" ? "year" : "month";
+ return { text: `${sym}${amount}`, per, once: t.interval === "once" };
}
// Make sure every member of a level holds its key, as far as THIS client can:
diff --git a/web/static/newchat.js b/web/static/newchat.js
index 72779c0..d958bd8 100644
--- a/web/static/newchat.js
+++ b/web/static/newchat.js
@@ -611,7 +611,7 @@ export async function showMembership() {
price.textContent = pr.text;
const per = document.createElement("span");
per.className = "per";
- per.textContent = " / " + pr.per;
+ per.textContent = pr.once ? " · once" : " / " + pr.per;
price.appendChild(per);
hd.appendChild(price);
}
@@ -624,7 +624,9 @@ export async function showMembership() {
dot.className = "dot";
st.appendChild(dot);
const txt = document.createElement("span");
- if (t.status === "active") {
+ if (t.lifetime && t.status === "active") {
+ txt.textContent = "Yours for keeps — paid once.";
+ } else if (t.status === "active") {
txt.textContent = t.paid_until ? `Renews on ${verbs.billingDate(t.paid_until)}` : "Active";
} else if (t.status === "past_due") {
st.classList.add("warn");
@@ -643,7 +645,10 @@ export async function showMembership() {
const acts = document.createElement("div");
acts.className = "plan-actions";
- if (t.status === "active") {
+ if (t.lifetime) {
+ // A one-time purchase has no billing to manage: no cancel, no
+ // resume, no portal — the box is just the good news above.
+ } else 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) {
@@ -695,7 +700,7 @@ export async function showMembership() {
price.textContent = pr.text;
const per = document.createElement("span");
per.className = "per";
- per.textContent = " / " + pr.per;
+ per.textContent = pr.once ? " · once" : " / " + pr.per;
price.appendChild(per);
hd.appendChild(price);
box.appendChild(hd);
@@ -775,7 +780,7 @@ function showChangeLevel(from, others, repaint, status) {
const pr = verbs.levelPrice(t);
const price = document.createElement("span");
price.className = "plan-price";
- price.textContent = `${pr.text} / ${pr.per}`;
+ price.textContent = pr.once ? `${pr.text} · once` : `${pr.text} / ${pr.per}`;
row.append(nm, price);
row.onclick = () => {
if (!confirm(`Switch to ${t.name} (${pr.text} / ${pr.per})?`)) return;