eleven / messenger Public

server: managed-payments provider core (phase 1, dark) (#1287)

* server: managed-payments provider core (docs/managed-payments.md phase 1, dark)

stripePlatform beside stripeConnect on the PayProvider seam: destination-
charge checkout (structural 89/11 split via transfer_data, no application
fee), platform-webhook parsing with space_ref binding (one platform account
carries every managed space's events — foreign and unstamped events drop),
platform-account Cancel/Resume/SwitchLevel/SubState/CreatePrice/Portal.
Selection: LCHAT_STRIPE_MANAGED_KEY/_WEBHOOK_SECRET take precedence over the
Connect pair; partial managed config is dormant, never a fallback to BYO.
Dark by construction: nothing writes stripe_express_account until the
phase-2 Express onboarding flow lands.

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

* server: managed commission is pay-what-you-want within 5.5–11%

The 11 becomes the suggestion: the admin's choice (managed_commission_pct,
written by the phase-2 UI) clamps to [5.5, 11] and sets the transfer
percent per checkout. Unset/unparseable → 11. The 5.5 floor is roughly the
platform's cost line — small international tickets can dip under it; priced
in (docs/managed-payments.md).

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cd48e0ad333b9d383a5aa75072c785c193f18d41 parent 71ba02d
3 files changed, +557 −4
  • subscriptions.go +21 −4
  • subscriptions_platform.go +323 −0
  • subscriptions_platform_test.go +213 −0
diff --git a/subscriptions.go b/subscriptions.go
index de6900b..51441f5 100644
--- a/subscriptions.go
+++ b/subscriptions.go
@@ -124,11 +124,28 @@ func (noPay) SubState(context.Context, string) (SubEvent, bool, error) {
// newPayProvider wires the payment backend from env. Tri-state, like
// newStripeBilling: all-empty → dormant (noPay); a partial config → dormant
-// with a warning (a half-config must never half-work); fully set → Stripe
-// Connect. The connected account itself is per-space (config table), set when
-// the admin links their Stripe account — so even with platform creds present,
-// a space with no connected account still can't sell.
+// with a warning (a half-config must never half-work); fully set → the
+// provider. Managed payments (docs/managed-payments.md) take precedence when
+// their creds are present — managed is the default provider wherever it's
+// deployed, and BYO Connect is the fallback. The per-space account (Express
+// or connected, config table) still gates actual selling — so even with
+// platform creds present, an unlinked space can't sell.
func newPayProvider(db *DB) PayProvider {
+ mkey := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_KEY"))
+ mwh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET"))
+ if mkey != "" || mwh != "" {
+ if mkey == "" || mwh == "" {
+ fmt.Fprintln(os.Stderr, "subscriptions: partial managed-payments config — set both LCHAT_STRIPE_MANAGED_KEY and LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET (paid access levels stay off)")
+ return noPay{}
+ }
+ return &stripePlatform{
+ db: db,
+ key: mkey,
+ webhookSecret: mwh,
+ apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"),
+ client: &http.Client{Timeout: 20 * time.Second},
+ }
+ }
key := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_KEY"))
wh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET"))
if key == "" && wh == "" {
diff --git a/subscriptions_platform.go b/subscriptions_platform.go
new file mode 100644
index 0000000..735f99d
--- /dev/null
+++ b/subscriptions_platform.go
@@ -0,0 +1,323 @@
+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
+)
+
+// 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 {
+ 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)
+}
+
+// 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("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)
+ 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"`
+ Metadata map[string]string `json:"metadata"`
+ }
+ if err := json.Unmarshal(ev.Data.Object, &o); err != nil || o.Mode != "subscription" {
+ return nil, nil
+ }
+ if o.Metadata["space_ref"] != acct {
+ 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 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
+}
diff --git a/subscriptions_platform_test.go b/subscriptions_platform_test.go
new file mode 100644
index 0000000..e0416c2
--- /dev/null
+++ b/subscriptions_platform_test.go
@@ -0,0 +1,213 @@
+package main
+
+// 🤖 Managed-payments provider core (docs/managed-payments.md phase 1): the
+// destination-charge split, the space binding on platform webhooks, and the
+// env selection order. The entitlement state machine is provider-independent
+// and already covered; these tests pin what stripePlatform sends and drops.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakePlatformStripe captures the last form posted to any path and answers
+// with a checkout URL. It also records whether a Stripe-Account header ever
+// arrived — a platform provider must never send one.
+func fakePlatformStripe(t *testing.T) (*httptest.Server, *url.Values, *[]string) {
+ t.Helper()
+ captured := &url.Values{}
+ var headers []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h := r.Header.Get("Stripe-Account"); h != "" {
+ headers = append(headers, h)
+ }
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ *captured = r.PostForm
+ writeJSON(w, http.StatusOK, map[string]any{
+ "id": "cs_test_1", "url": "https://checkout.stripe.com/c/pay/cs_test_1",
+ })
+ }))
+ t.Cleanup(srv.Close)
+ return srv, captured, &headers
+}
+
+func newTestPlatform(t *testing.T, srv *Server, apiBase string) *stripePlatform {
+ t.Helper()
+ return &stripePlatform{
+ db: srv.db, key: "sk_test_platform", webhookSecret: "whsec_platform",
+ apiBase: apiBase, client: http.DefaultClient,
+ }
+}
+
+func TestManagedCheckoutIsDestinationCharge(t *testing.T) {
+ _, srv := newTestServer(t)
+ stripe, captured, headers := fakePlatformStripe(t)
+ p := newTestPlatform(t, srv, stripe.URL)
+
+ // Dark until the Express account exists: not configured, checkout refused.
+ if p.Configured() {
+ t.Fatal("Configured() = true with no Express account — the dark gate is the point of phase 1")
+ }
+ if _, err := p.Checkout(context.Background(), CheckoutReq{}); err != errBillingOff {
+ t.Fatalf("checkout without account: %v, want errBillingOff", err)
+ }
+
+ if err := srv.db.setConfig("stripe_express_account", "acct_express_1"); err != nil {
+ t.Fatal(err)
+ }
+ u, err := p.Checkout(context.Background(), CheckoutReq{
+ TierID: "tier1", PriceRef: "price_1", UserRef: "uid1",
+ SuccessURL: "https://s.example/ok", CancelURL: "https://s.example/no",
+ })
+ if err != nil || !strings.Contains(u, "checkout.stripe.com") {
+ t.Fatalf("checkout: %q, %v", u, err)
+ }
+ f := *captured
+ // The structural split at the suggested 11: 89% transfers, no
+ // application fee anywhere.
+ if f.Get("subscription_data[transfer_data][destination]") != "acct_express_1" ||
+ f.Get("subscription_data[transfer_data][amount_percent]") != "89" {
+ t.Errorf("transfer_data = %v, want acct_express_1 at 89%%", f)
+ }
+ for k := range f {
+ if strings.Contains(k, "application_fee") {
+ t.Errorf("managed checkout sent an application fee field: %s", k)
+ }
+ }
+ // The space binding rides both the session and the subscription.
+ if f.Get("metadata[space_ref]") != "acct_express_1" || f.Get("subscription_data[metadata][space_ref]") != "acct_express_1" {
+ t.Errorf("space binding missing: %v", f)
+ }
+ // Platform account means no Stripe-Account header, ever.
+ if len(*headers) != 0 {
+ t.Errorf("Stripe-Account header sent on a platform call: %v", *headers)
+ }
+}
+
+// The commission is pay-what-you-want within [5.5, 11]: the config picks,
+// out-of-range clamps, garbage falls back to the suggested 11.
+func TestManagedCommissionRange(t *testing.T) {
+ _, srv := newTestServer(t)
+ p := newTestPlatform(t, srv, "http://unused")
+ for cfg, want := range map[string]string{
+ "": "89", // unset → suggested 11
+ "11": "89", // explicit suggestion
+ "5.5": "94.5", // the floor
+ "8": "92", // anywhere between
+ "3": "94.5", // below the floor clamps up
+ "50": "89", // above the range clamps to 11
+ "junk": "89", // unparseable → suggested, never a freebie
+ } {
+ if cfg != "" {
+ if err := srv.db.setConfig("managed_commission_pct", cfg); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := p.managedTransferPercent(); got != want {
+ t.Errorf("commission %q → transfer %s, want %s", cfg, got, want)
+ }
+ }
+}
+
+// platformEvent builds a signed platform webhook body.
+func platformEvent(t *testing.T, evType string, object map[string]any) (body []byte, sig string) {
+ t.Helper()
+ raw, _ := json.Marshal(map[string]any{
+ "type": evType, "created": 1000,
+ "data": map[string]any{"object": object},
+ })
+ return raw, stripeSign(raw, "whsec_platform", time.Now())
+}
+
+func TestManagedWebhookBindsToSpace(t *testing.T) {
+ _, srv := newTestServer(t)
+ p := newTestPlatform(t, srv, "http://unused")
+ if err := srv.db.setConfig("stripe_express_account", "acct_ours"); err != nil {
+ t.Fatal(err)
+ }
+
+ ours := map[string]any{
+ "mode": "subscription", "customer": "cus1", "subscription": "sub1",
+ "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"},
+ }
+ body, sig := platformEvent(t, "checkout.session.completed", ours)
+ evs, err := p.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 || evs[0].ExtSub != "sub1" || !evs[0].AllowCreate || !evs[0].Checkout {
+ t.Fatalf("our event: %+v, %v", evs, err)
+ }
+
+ // Foreign space's event (same platform account, valid signature): dropped.
+ foreign := map[string]any{
+ "mode": "subscription", "customer": "cus2", "subscription": "sub2",
+ "metadata": map[string]any{"user_ref": "u2", "tier_ref": "t2", "space_ref": "acct_theirs"},
+ }
+ body, sig = platformEvent(t, "checkout.session.completed", foreign)
+ if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil {
+ t.Fatalf("foreign event must drop silently: %+v, %v", evs, err)
+ }
+
+ // Unstamped event (no space_ref at all): dropped, not guessed.
+ unstamped := map[string]any{
+ "id": "sub3", "customer": "cus3", "status": "active",
+ "metadata": map[string]any{"user_ref": "u3", "tier_ref": "t3"},
+ }
+ body, sig = platformEvent(t, "customer.subscription.updated", unstamped)
+ if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil {
+ t.Fatalf("unstamped event must drop: %+v, %v", evs, err)
+ }
+
+ // Our subscription lifecycle event: normalized like the BYO provider's.
+ subUpd := map[string]any{
+ "id": "sub1", "customer": "cus1", "status": "past_due",
+ "current_period_end": 99999, "cancel_at_period_end": true,
+ "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"},
+ }
+ body, sig = platformEvent(t, "customer.subscription.updated", subUpd)
+ evs, err = p.ParseWebhook(sig, body)
+ if err != nil || len(evs) != 1 {
+ t.Fatalf("sub event: %+v, %v", evs, err)
+ }
+ if e := evs[0]; e.Status != "past_due" || !e.CancelAtPeriodEnd || e.CurrentPeriodEnd != 99999 || e.AllowCreate {
+ t.Errorf("normalized = %+v", e)
+ }
+
+ // Bad signature: hard error (Stripe retries), never a silent drop.
+ if _, err := p.ParseWebhook("t=1,v1=deadbeef", body); err == nil {
+ t.Error("bad signature accepted")
+ }
+}
+
+func TestManagedProviderSelection(t *testing.T) {
+ _, srv := newTestServer(t)
+
+ // Managed creds win over Connect creds — managed is the default provider.
+ t.Setenv("LCHAT_STRIPE_MANAGED_KEY", "sk_m")
+ t.Setenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET", "whsec_m")
+ t.Setenv("LCHAT_STRIPE_CONNECT_KEY", "sk_c")
+ t.Setenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET", "whsec_c")
+ if _, ok := newPayProvider(srv.db).(*stripePlatform); !ok {
+ t.Error("managed creds should select stripePlatform")
+ }
+
+ // Partial managed config is a deployment mistake: dormant, never half-working
+ // (and never a silent fallback to BYO — that would sell on the wrong model).
+ t.Setenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET", "")
+ if _, ok := newPayProvider(srv.db).(noPay); !ok {
+ t.Error("partial managed config should be dormant")
+ }
+
+ // No managed creds: BYO Connect as today.
+ t.Setenv("LCHAT_STRIPE_MANAGED_KEY", "")
+ if _, ok := newPayProvider(srv.db).(*stripeConnect); !ok {
+ t.Error("connect creds should select stripeConnect")
+ }
+}