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>
3 files changed,
+557
−4
subscriptions.go+21 −4subscriptions_platform.go+323 −0subscriptions_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") |
| + } |
| +} |