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") } }