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>
10 files changed,
+645
−57
changelog/2026-07-22-lifetime-access.md+5 −0changelog/2026-07-27-commission-5-5.md+3 −0db.go+45 −9subscriptions.go+111 −17subscriptions_handlers.go+49 −3subscriptions_lifetime_test.go+344 −0subscriptions_platform.go+73 −19web/static/apps/admin.js+2 −2web/static/billingui.js+3 −2web/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; |