package main // 🤖 Managed payments (docs/managed-payments.md, phase 1): the payout-model // provider for paid access levels. The platform is merchant of record — // charges run on the PLATFORM Stripe account and each invoice transfers 89% // to the space's Express account via destination charges, so the admin's cut // is structural (transfer_data), never reconciled. Card processing fees land // on the platform side by construction: "11% all-in" is how the charge is // shaped, not a promise kept by refunds. // // This file is deliberately DARK until phase 2: nothing writes the // stripe_express_account config key yet (the Express onboarding flow does), // and without it Configured() is false, so the feature stays dormant even // with platform creds deployed. stripeConnect (BYO, subscriptions.go) is // untouched and remains the live path. import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // The platform's managed commission is pay-what-you-want within a range // (docs/managed-payments.md): the admin picks 5.5–11%, suggested 11, and // processing fees come out of ours, never theirs. The choice lives in the // space config (managed_commission_pct, written by the phase-2 UI); the // transfer percent is its complement. The floor is roughly our cost line — // small tickets on international cards can dip under it, priced in. const ( managedKeepDefaultPct = 11.0 managedKeepMinPct = 5.5 ) // 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 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 // stripeConnect, but every call runs on the platform account (no // Stripe-Account header) and checkout routes 89% to the space's Express // account. The Express account id lives in the space config, set by the // phase-2 onboarding flow. type stripePlatform struct { db *DB key string // platform secret key webhookSecret string apiBase string client *http.Client } // expressAccount is the space's Stripe Express account id ("acct_…"), where // the 89% lands. Empty → the space can't sell on managed payments yet. func (p *stripePlatform) expressAccount() string { v, _, _ := p.db.getConfig("stripe_express_account") return v } func (p *stripePlatform) Configured() bool { return p.expressAccount() != "" } // post form-encodes to the Stripe API on the platform account. func (p *stripePlatform) post(ctx context.Context, path string, form url.Values) (map[string]any, error) { if p.expressAccount() == "" { return nil, errBillingOff } req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.key) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { msg := "" if e, ok := body["error"].(map[string]any); ok { msg, _ = e["message"].(string) } return nil, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, msg) } return body, nil } // get reads a Stripe object on the platform account. func (p *stripePlatform) get(ctx context.Context, path string) (map[string]any, error) { if p.expressAccount() == "" { return nil, errBillingOff } req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("stripe: HTTP %d", resp.StatusCode) } return body, nil } func (p *stripePlatform) Checkout(ctx context.Context, r CheckoutReq) (string, error) { acct := p.expressAccount() form := url.Values{} 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) // 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("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 } u, _ := body["url"].(string) if u == "" { return "", fmt.Errorf("stripe: session created but no url") } return u, nil } func (p *stripePlatform) Cancel(ctx context.Context, extSub string) error { // Cancel at period end, exactly like the BYO provider: access runs to the // end of the paid period, no further charge. form := url.Values{} form.Set("cancel_at_period_end", "true") _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } func (p *stripePlatform) Resume(ctx context.Context, extSub string) error { form := url.Values{} form.Set("cancel_at_period_end", "false") _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } func (p *stripePlatform) SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error { sub, err := p.get(ctx, "/v1/subscriptions/"+url.PathEscape(extSub)) if err != nil { return err } itemID := "" if items, ok := sub["items"].(map[string]any); ok { if data, ok := items["data"].([]any); ok && len(data) > 0 { if it, ok := data[0].(map[string]any); ok { itemID, _ = it["id"].(string) } } } if itemID == "" { return fmt.Errorf("stripe: subscription %s has no item to switch", extSub) } form := url.Values{} form.Set("items[0][id]", itemID) form.Set("items[0][price]", newPriceRef) form.Set("proration_behavior", "create_prorations") form.Set("metadata[tier_ref]", newTierRef) _, err = p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } func (p *stripePlatform) SubState(ctx context.Context, extSub string) (SubEvent, bool, error) { if p.expressAccount() == "" { return SubEvent{}, false, errBillingOff } req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+"/v1/subscriptions/"+url.PathEscape(extSub), nil) if err != nil { return SubEvent{}, false, err } req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return SubEvent{}, false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return SubEvent{}, false, nil } var o subObject if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&o); err != nil { return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d", resp.StatusCode) } return o.subEvent(), true, nil } func (p *stripePlatform) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) { // Managed levels' Prices live on the PLATFORM account (we are the // merchant); the admin never needs a Stripe dashboard. Inline product, // like the BYO path. form := url.Values{} form.Set("product_data[name]", name) form.Set("unit_amount", fmt.Sprintf("%d", cents)) form.Set("currency", currency) form.Set("recurring[interval]", interval) body, err := p.post(ctx, "/v1/prices", form) if err != nil { return "", err } id, _ := body["id"].(string) if id == "" { return "", fmt.Errorf("stripe: price created but no id") } return id, nil } func (p *stripePlatform) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) { form := url.Values{} form.Set("customer", extCustomer) form.Set("return_url", returnURL) body, err := p.post(ctx, "/v1/billing_portal/sessions", form) if err != nil { return "", err } u, _ := body["url"].(string) return u, nil } // ParseWebhook verifies a platform-account webhook and normalizes it. The // platform endpoint receives EVERY managed space's events (they all live on // one Stripe account), so a valid signature is not proof the event is ours — // the space binding is the space_ref metadata stamped at checkout, matched // against THIS space's Express account. Foreign or unstamped events are // dropped (acknowledged, never retried) — same posture as the BYO provider's // connected-account binding, for the same forgery reason. func (p *stripePlatform) ParseWebhook(sig string, body []byte) ([]SubEvent, error) { if !verifyStripeSignature(body, sig, p.webhookSecret, time.Now()) { return nil, errors.New("webhook signature verification failed") } var ev struct { Type string `json:"type"` Created int64 `json:"created"` Data struct { Object json.RawMessage `json:"object"` } `json:"data"` } if err := json.Unmarshal(body, &ev); err != nil { return nil, err } acct := p.expressAccount() if acct == "" { return nil, nil } switch { case ev.Type == "checkout.session.completed": var o struct { Mode string `json:"mode"` Customer string `json:"customer"` Subscription string `json:"subscription"` PaymentIntent string `json:"payment_intent"` Metadata map[string]string `json:"metadata"` } 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{{ ExtSub: o.Subscription, ExtCustomer: o.Customer, 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 { return nil, err } if o.Metadata["space_ref"] != acct { return nil, nil } se := o.subEvent() se.Deleted = ev.Type == "customer.subscription.deleted" se.Created = ev.Created se.AllowCreate = ev.Type == "customer.subscription.created" return []SubEvent{se}, nil } return nil, nil }