| 1 | package main |
| 2 | |
| 3 | // π€ Managed-payments provider core (docs/managed-payments.md phase 1): the |
| 4 | // destination-charge split, the space binding on platform webhooks, and the |
| 5 | // env selection order. The entitlement state machine is provider-independent |
| 6 | // and already covered; these tests pin what stripePlatform sends and drops. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "net/url" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | // fakePlatformStripe captures the last form posted to any path and answers |
| 20 | // with a checkout URL. It also records whether a Stripe-Account header ever |
| 21 | // arrived β a platform provider must never send one. |
| 22 | func fakePlatformStripe(t *testing.T) (*httptest.Server, *url.Values, *[]string) { |
| 23 | t.Helper() |
| 24 | captured := &url.Values{} |
| 25 | var headers []string |
| 26 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 27 | if h := r.Header.Get("Stripe-Account"); h != "" { |
| 28 | headers = append(headers, h) |
| 29 | } |
| 30 | if err := r.ParseForm(); err != nil { |
| 31 | w.WriteHeader(http.StatusBadRequest) |
| 32 | return |
| 33 | } |
| 34 | *captured = r.PostForm |
| 35 | writeJSON(w, http.StatusOK, map[string]any{ |
| 36 | "id": "cs_test_1", "url": "https://checkout.stripe.com/c/pay/cs_test_1", |
| 37 | }) |
| 38 | })) |
| 39 | t.Cleanup(srv.Close) |
| 40 | return srv, captured, &headers |
| 41 | } |
| 42 | |
| 43 | func newTestPlatform(t *testing.T, srv *Server, apiBase string) *stripePlatform { |
| 44 | t.Helper() |
| 45 | return &stripePlatform{ |
| 46 | db: srv.db, key: "sk_test_platform", webhookSecret: "whsec_platform", |
| 47 | apiBase: apiBase, client: http.DefaultClient, |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestManagedCheckoutIsDestinationCharge(t *testing.T) { |
| 52 | _, srv := newTestServer(t) |
| 53 | stripe, captured, headers := fakePlatformStripe(t) |
| 54 | p := newTestPlatform(t, srv, stripe.URL) |
| 55 | |
| 56 | // Dark until the Express account exists: not configured, checkout refused. |
| 57 | if p.Configured() { |
| 58 | t.Fatal("Configured() = true with no Express account β the dark gate is the point of phase 1") |
| 59 | } |
| 60 | if _, err := p.Checkout(context.Background(), CheckoutReq{}); err != errBillingOff { |
| 61 | t.Fatalf("checkout without account: %v, want errBillingOff", err) |
| 62 | } |
| 63 | |
| 64 | if err := srv.db.setConfig("stripe_express_account", "acct_express_1"); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | u, err := p.Checkout(context.Background(), CheckoutReq{ |
| 68 | TierID: "tier1", PriceRef: "price_1", UserRef: "uid1", |
| 69 | SuccessURL: "https://s.example/ok", CancelURL: "https://s.example/no", |
| 70 | }) |
| 71 | if err != nil || !strings.Contains(u, "checkout.stripe.com") { |
| 72 | t.Fatalf("checkout: %q, %v", u, err) |
| 73 | } |
| 74 | f := *captured |
| 75 | // The structural split at the suggested 11: 89% transfers, no |
| 76 | // application fee anywhere. |
| 77 | if f.Get("subscription_data[transfer_data][destination]") != "acct_express_1" || |
| 78 | f.Get("subscription_data[transfer_data][amount_percent]") != "89" { |
| 79 | t.Errorf("transfer_data = %v, want acct_express_1 at 89%%", f) |
| 80 | } |
| 81 | for k := range f { |
| 82 | if strings.Contains(k, "application_fee") { |
| 83 | t.Errorf("managed checkout sent an application fee field: %s", k) |
| 84 | } |
| 85 | } |
| 86 | // The space binding rides both the session and the subscription. |
| 87 | if f.Get("metadata[space_ref]") != "acct_express_1" || f.Get("subscription_data[metadata][space_ref]") != "acct_express_1" { |
| 88 | t.Errorf("space binding missing: %v", f) |
| 89 | } |
| 90 | // Platform account means no Stripe-Account header, ever. |
| 91 | if len(*headers) != 0 { |
| 92 | t.Errorf("Stripe-Account header sent on a platform call: %v", *headers) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // The commission is pay-what-you-want within [5.5, 11]: the config picks, |
| 97 | // out-of-range clamps, garbage falls back to the suggested 11. |
| 98 | func TestManagedCommissionRange(t *testing.T) { |
| 99 | _, srv := newTestServer(t) |
| 100 | p := newTestPlatform(t, srv, "http://unused") |
| 101 | for cfg, want := range map[string]string{ |
| 102 | "": "89", // unset β suggested 11 |
| 103 | "11": "89", // explicit suggestion |
| 104 | "5.5": "94.5", // the floor |
| 105 | "8": "92", // anywhere between |
| 106 | "3": "94.5", // below the floor clamps up |
| 107 | "50": "89", // above the range clamps to 11 |
| 108 | "junk": "89", // unparseable β suggested, never a freebie |
| 109 | } { |
| 110 | if cfg != "" { |
| 111 | if err := srv.db.setConfig("managed_commission_pct", cfg); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | } |
| 115 | if got := p.managedTransferPercent(); got != want { |
| 116 | t.Errorf("commission %q β transfer %s, want %s", cfg, got, want) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // platformEvent builds a signed platform webhook body. |
| 122 | func platformEvent(t *testing.T, evType string, object map[string]any) (body []byte, sig string) { |
| 123 | t.Helper() |
| 124 | raw, _ := json.Marshal(map[string]any{ |
| 125 | "type": evType, "created": 1000, |
| 126 | "data": map[string]any{"object": object}, |
| 127 | }) |
| 128 | return raw, stripeSign(raw, "whsec_platform", time.Now()) |
| 129 | } |
| 130 | |
| 131 | func TestManagedWebhookBindsToSpace(t *testing.T) { |
| 132 | _, srv := newTestServer(t) |
| 133 | p := newTestPlatform(t, srv, "http://unused") |
| 134 | if err := srv.db.setConfig("stripe_express_account", "acct_ours"); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | |
| 138 | ours := map[string]any{ |
| 139 | "mode": "subscription", "customer": "cus1", "subscription": "sub1", |
| 140 | "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"}, |
| 141 | } |
| 142 | body, sig := platformEvent(t, "checkout.session.completed", ours) |
| 143 | evs, err := p.ParseWebhook(sig, body) |
| 144 | if err != nil || len(evs) != 1 || evs[0].ExtSub != "sub1" || !evs[0].AllowCreate || !evs[0].Checkout { |
| 145 | t.Fatalf("our event: %+v, %v", evs, err) |
| 146 | } |
| 147 | |
| 148 | // Foreign space's event (same platform account, valid signature): dropped. |
| 149 | foreign := map[string]any{ |
| 150 | "mode": "subscription", "customer": "cus2", "subscription": "sub2", |
| 151 | "metadata": map[string]any{"user_ref": "u2", "tier_ref": "t2", "space_ref": "acct_theirs"}, |
| 152 | } |
| 153 | body, sig = platformEvent(t, "checkout.session.completed", foreign) |
| 154 | if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil { |
| 155 | t.Fatalf("foreign event must drop silently: %+v, %v", evs, err) |
| 156 | } |
| 157 | |
| 158 | // Unstamped event (no space_ref at all): dropped, not guessed. |
| 159 | unstamped := map[string]any{ |
| 160 | "id": "sub3", "customer": "cus3", "status": "active", |
| 161 | "metadata": map[string]any{"user_ref": "u3", "tier_ref": "t3"}, |
| 162 | } |
| 163 | body, sig = platformEvent(t, "customer.subscription.updated", unstamped) |
| 164 | if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil { |
| 165 | t.Fatalf("unstamped event must drop: %+v, %v", evs, err) |
| 166 | } |
| 167 | |
| 168 | // Our subscription lifecycle event: normalized like the BYO provider's. |
| 169 | subUpd := map[string]any{ |
| 170 | "id": "sub1", "customer": "cus1", "status": "past_due", |
| 171 | "current_period_end": 99999, "cancel_at_period_end": true, |
| 172 | "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"}, |
| 173 | } |
| 174 | body, sig = platformEvent(t, "customer.subscription.updated", subUpd) |
| 175 | evs, err = p.ParseWebhook(sig, body) |
| 176 | if err != nil || len(evs) != 1 { |
| 177 | t.Fatalf("sub event: %+v, %v", evs, err) |
| 178 | } |
| 179 | if e := evs[0]; e.Status != "past_due" || !e.CancelAtPeriodEnd || e.CurrentPeriodEnd != 99999 || e.AllowCreate { |
| 180 | t.Errorf("normalized = %+v", e) |
| 181 | } |
| 182 | |
| 183 | // Bad signature: hard error (Stripe retries), never a silent drop. |
| 184 | if _, err := p.ParseWebhook("t=1,v1=deadbeef", body); err == nil { |
| 185 | t.Error("bad signature accepted") |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestManagedProviderSelection(t *testing.T) { |
| 190 | _, srv := newTestServer(t) |
| 191 | |
| 192 | // Managed creds win over Connect creds β managed is the default provider. |
| 193 | t.Setenv("LCHAT_STRIPE_MANAGED_KEY", "sk_m") |
| 194 | t.Setenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET", "whsec_m") |
| 195 | t.Setenv("LCHAT_STRIPE_CONNECT_KEY", "sk_c") |
| 196 | t.Setenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET", "whsec_c") |
| 197 | if _, ok := newPayProvider(srv.db).(*stripePlatform); !ok { |
| 198 | t.Error("managed creds should select stripePlatform") |
| 199 | } |
| 200 | |
| 201 | // Partial managed config is a deployment mistake: dormant, never half-working |
| 202 | // (and never a silent fallback to BYO β that would sell on the wrong model). |
| 203 | t.Setenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET", "") |
| 204 | if _, ok := newPayProvider(srv.db).(noPay); !ok { |
| 205 | t.Error("partial managed config should be dormant") |
| 206 | } |
| 207 | |
| 208 | // No managed creds: BYO Connect as today. |
| 209 | t.Setenv("LCHAT_STRIPE_MANAGED_KEY", "") |
| 210 | if _, ok := newPayProvider(srv.db).(*stripeConnect); !ok { |
| 211 | t.Error("connect creds should select stripeConnect") |
| 212 | } |
| 213 | } |
| 214 | |