| 1 | package main |
| 2 | |
| 3 | // π€ Lifetime access levels (docs/managed-payments.md Β§ Lifetime): pay once, |
| 4 | // in forever. These pin the four load-bearing properties: the grant has no |
| 5 | // lifecycle (no sweep, no repair pass may ever touch it), a refund/chargeback |
| 6 | // un-buys it immediately, both providers mint payment-mode checkouts with the |
| 7 | // commission applied as a fixed amount, and the renewal-shaped member |
| 8 | // endpoints (cancel/resume/change/portal) answer honestly instead of |
| 9 | // confusing Stripe with a payment-intent id. |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "encoding/json" |
| 14 | "fmt" |
| 15 | "net/http" |
| 16 | "strconv" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // countingPay is a configured provider that counts SubState fetches β the |
| 23 | // repair-pass skip is the property under test, so the count is the assert. |
| 24 | type countingPay struct { |
| 25 | noPay |
| 26 | subStateCalls int |
| 27 | } |
| 28 | |
| 29 | func (c *countingPay) Configured() bool { return true } |
| 30 | func (c *countingPay) SubState(context.Context, string) (SubEvent, bool, error) { |
| 31 | c.subStateCalls++ |
| 32 | return SubEvent{}, false, nil |
| 33 | } |
| 34 | |
| 35 | // lifetimeGrant is the event a completed payment-mode checkout produces. |
| 36 | func lifetimeGrant(uid, tid string, now int64) SubEvent { |
| 37 | return SubEvent{ |
| 38 | ExtSub: "pi_life_1", ExtCustomer: "cus_1", UserRef: uid, TierRef: tid, |
| 39 | Status: "active", Created: now, AllowCreate: true, Lifetime: true, |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestLifetimeGrantNeverSwept(t *testing.T) { |
| 44 | _, srv := newTestServer(t) |
| 45 | tid, uid := seedTierAndMember(t, srv, "Founder", "bea") |
| 46 | now := int64(3_000_000) |
| 47 | |
| 48 | if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if !srv.db.tierMember(tid, uid) { |
| 52 | t.Fatal("member not assigned after lifetime purchase") |
| 53 | } |
| 54 | e, ok := srv.db.memberEntitlement(tid, uid) |
| 55 | if !ok || !e.Lifetime || e.Status != "active" || e.ExtSub != "pi_life_1" { |
| 56 | t.Fatalf("entitlement = %+v ok=%v, want active lifetime on pi_life_1", e, ok) |
| 57 | } |
| 58 | |
| 59 | // A decade of sweeps: the grace clause can't match (no deadline), the |
| 60 | // silent-lapse backstop can't match (no paid_until), and the repair pass |
| 61 | // must SKIP the row β there is no subscription to re-fetch, and a |
| 62 | // not-found answer would revoke a valid purchase. |
| 63 | fake := &countingPay{} |
| 64 | srv.pay = fake |
| 65 | tenYears := now + 10*365*24*3600 |
| 66 | if n := srv.runBillingSweep(tenYears); n != 0 { |
| 67 | t.Fatalf("sweep revoked %d membership(s) β a lifetime purchase expired", n) |
| 68 | } |
| 69 | if !srv.db.tierMember(tid, uid) { |
| 70 | t.Fatal("lifetime membership gone after sweeps") |
| 71 | } |
| 72 | if fake.subStateCalls != 0 { |
| 73 | t.Fatalf("repair pass fetched provider state %d time(s) for a lifetime row", fake.subStateCalls) |
| 74 | } |
| 75 | |
| 76 | // Redelivery of the same grant is a no-op, not a duplicate. |
| 77 | if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if !srv.db.tierMember(tid, uid) { |
| 81 | t.Fatal("redelivered grant broke the membership") |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestLifetimeRefundRevokes(t *testing.T) { |
| 86 | _, srv := newTestServer(t) |
| 87 | tid, uid := seedTierAndMember(t, srv, "Founder", "bea") |
| 88 | now := int64(3_000_000) |
| 89 | if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | |
| 93 | // The refund/chargeback event: revoke is immediate β grace windows exist |
| 94 | // to survive a failing card, and nothing here is failing. |
| 95 | refund := SubEvent{ |
| 96 | ExtSub: "pi_life_1", UserRef: uid, TierRef: tid, |
| 97 | Status: "canceled", Created: now + 100, Deleted: true, Lifetime: true, |
| 98 | } |
| 99 | if err := srv.applyWebhookEvent(context.Background(), refund, now+100); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if srv.db.tierMember(tid, uid) { |
| 103 | t.Fatal("membership survived a refund") |
| 104 | } |
| 105 | // And the no-resurrect property holds: a redelivered grant older than the |
| 106 | // refund must not re-buy the level. |
| 107 | if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now+200); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | if srv.db.tierMember(tid, uid) { |
| 111 | t.Fatal("stale redelivered grant resurrected a refunded purchase") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // connectTestProvider is a stripeConnect against a captured fake Stripe. |
| 116 | func connectTestProvider(t *testing.T, srv *Server) (*stripeConnect, *map[string][]string) { |
| 117 | t.Helper() |
| 118 | stripe, captured, _ := fakePlatformStripe(t) |
| 119 | if err := srv.db.setConfig("stripe_connect_account", "acct_admin"); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | p := &stripeConnect{db: srv.db, key: "sk", webhookSecret: "whsec_connect", apiBase: stripe.URL, client: http.DefaultClient} |
| 123 | return p, (*map[string][]string)(captured) |
| 124 | } |
| 125 | |
| 126 | func TestLifetimeCheckoutForms(t *testing.T) { |
| 127 | _, srv := newTestServer(t) |
| 128 | |
| 129 | // BYO: mode=payment, the fee as a fixed amount (5.5% of the price), the |
| 130 | // routing metadata mirrored onto the payment intent for refund routing, |
| 131 | // and nothing subscription-shaped. |
| 132 | pc, capturedC := connectTestProvider(t, srv) |
| 133 | if _, err := pc.Checkout(context.Background(), CheckoutReq{ |
| 134 | TierID: "t1", PriceRef: "price_once", UserRef: "u1", |
| 135 | Once: true, AmountMinor: 11000, |
| 136 | SuccessURL: "https://s/ok", CancelURL: "https://s/no", |
| 137 | }); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | fc := *capturedC |
| 141 | if got := fc["mode"]; len(got) != 1 || got[0] != "payment" { |
| 142 | t.Errorf("BYO mode = %v", got) |
| 143 | } |
| 144 | if got := fc["payment_intent_data[application_fee_amount]"]; len(got) != 1 || got[0] != "605" { |
| 145 | t.Errorf("BYO fee = %v, want 605 (5.5%% of 11000)", got) |
| 146 | } |
| 147 | if got := fc["payment_intent_data[metadata][tier_ref]"]; len(got) != 1 || got[0] != "t1" { |
| 148 | t.Errorf("BYO intent metadata = %v", fc) |
| 149 | } |
| 150 | for k := range fc { |
| 151 | if strings.Contains(k, "subscription_data") { |
| 152 | t.Errorf("BYO once checkout sent %s", k) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // Managed: mode=payment, the admin's share as a fixed transfer computed |
| 157 | // from the chosen commission (8% of 11000 β 10120 transfers). |
| 158 | if err := srv.db.setConfig("stripe_express_account", "acct_express"); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | if err := srv.db.setConfig("managed_commission_pct", "8"); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | stripe, capturedP, _ := fakePlatformStripe(t) |
| 165 | pp := &stripePlatform{db: srv.db, key: "sk", webhookSecret: "whsec_platform", apiBase: stripe.URL, client: http.DefaultClient} |
| 166 | if _, err := pp.Checkout(context.Background(), CheckoutReq{ |
| 167 | TierID: "t1", PriceRef: "price_once", UserRef: "u1", |
| 168 | Once: true, AmountMinor: 11000, |
| 169 | SuccessURL: "https://s/ok", CancelURL: "https://s/no", |
| 170 | }); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | fp := *capturedP |
| 174 | if got := fp.Get("payment_intent_data[transfer_data][amount]"); got != "10120" { |
| 175 | t.Errorf("managed transfer = %q, want 10120 (92%% of 11000)", got) |
| 176 | } |
| 177 | if got := fp.Get("payment_intent_data[transfer_data][destination]"); got != "acct_express" { |
| 178 | t.Errorf("managed destination = %q", got) |
| 179 | } |
| 180 | for k := range fp { |
| 181 | if strings.Contains(k, "subscription_data") || strings.Contains(k, "application_fee") { |
| 182 | t.Errorf("managed once checkout sent %s", k) |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestLifetimeWebhookParsing(t *testing.T) { |
| 188 | _, srv := newTestServer(t) |
| 189 | if err := srv.db.setConfig("stripe_express_account", "acct_ours"); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | p := &stripePlatform{db: srv.db, key: "sk", webhookSecret: "whsec_platform", apiBase: "http://unused", client: http.DefaultClient} |
| 193 | |
| 194 | // A payment-mode session with our binding β a lifetime grant on the |
| 195 | // payment intent id. |
| 196 | session := map[string]any{ |
| 197 | "mode": "payment", "customer": "cus1", "payment_intent": "pi_1", |
| 198 | "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"}, |
| 199 | } |
| 200 | body, sig := platformEvent(t, "checkout.session.completed", session) |
| 201 | evs, err := p.ParseWebhook(sig, body) |
| 202 | if err != nil || len(evs) != 1 { |
| 203 | t.Fatalf("grant: %+v, %v", evs, err) |
| 204 | } |
| 205 | if e := evs[0]; !e.Lifetime || !e.AllowCreate || e.Checkout || e.ExtSub != "pi_1" { |
| 206 | t.Errorf("grant = %+v, want lifetime AllowCreate on pi_1, never a receipt", e) |
| 207 | } |
| 208 | |
| 209 | // A donate/enterprise-shaped payment session (no binding): dropped. |
| 210 | stray := map[string]any{"mode": "payment", "payment_intent": "pi_x", "metadata": map[string]any{"donation": "1"}} |
| 211 | body, sig = platformEvent(t, "checkout.session.completed", stray) |
| 212 | if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil { |
| 213 | t.Fatalf("stray payment session must drop: %+v, %v", evs, err) |
| 214 | } |
| 215 | |
| 216 | // charge.refunded with our stamped metadata β the un-buy event. |
| 217 | refund := map[string]any{ |
| 218 | "payment_intent": "pi_1", |
| 219 | "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1", "space_ref": "acct_ours"}, |
| 220 | } |
| 221 | body, sig = platformEvent(t, "charge.refunded", refund) |
| 222 | evs, err = p.ParseWebhook(sig, body) |
| 223 | if err != nil || len(evs) != 1 || !evs[0].Deleted || !evs[0].Lifetime || evs[0].ExtSub != "pi_1" { |
| 224 | t.Fatalf("refund: %+v, %v", evs, err) |
| 225 | } |
| 226 | // A refund on some other space's charge: dropped. |
| 227 | foreign := map[string]any{ |
| 228 | "payment_intent": "pi_2", |
| 229 | "metadata": map[string]any{"tier_ref": "t9", "space_ref": "acct_theirs"}, |
| 230 | } |
| 231 | body, sig = platformEvent(t, "charge.refunded", foreign) |
| 232 | if evs, err := p.ParseWebhook(sig, body); err != nil || evs != nil { |
| 233 | t.Fatalf("foreign refund must drop: %+v, %v", evs, err) |
| 234 | } |
| 235 | |
| 236 | // The BYO provider: same shapes, bound by connected account instead. |
| 237 | if err := srv.db.setConfig("stripe_connect_account", "acct_admin"); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | pc := &stripeConnect{db: srv.db, key: "sk", webhookSecret: "whsec_connect", apiBase: "http://unused", client: http.DefaultClient} |
| 241 | connectEvent := func(evType string, object map[string]any) ([]byte, string) { |
| 242 | raw, _ := json.Marshal(map[string]any{ |
| 243 | "type": evType, "account": "acct_admin", "created": 1000, |
| 244 | "data": map[string]any{"object": object}, |
| 245 | }) |
| 246 | return raw, stripeSign(raw, "whsec_connect", time.Now()) |
| 247 | } |
| 248 | sess := map[string]any{ |
| 249 | "mode": "payment", "customer": "cus1", "payment_intent": "pi_c1", |
| 250 | "metadata": map[string]any{"user_ref": "u1", "tier_ref": "t1"}, |
| 251 | } |
| 252 | body, sig = connectEvent("checkout.session.completed", sess) |
| 253 | evs, err = pc.ParseWebhook(sig, body) |
| 254 | if err != nil || len(evs) != 1 || !evs[0].Lifetime || evs[0].ExtSub != "pi_c1" { |
| 255 | t.Fatalf("BYO grant: %+v, %v", evs, err) |
| 256 | } |
| 257 | body, sig = connectEvent("charge.refunded", map[string]any{ |
| 258 | "payment_intent": "pi_c1", "metadata": map[string]any{"tier_ref": "t1", "user_ref": "u1"}, |
| 259 | }) |
| 260 | evs, err = pc.ParseWebhook(sig, body) |
| 261 | if err != nil || len(evs) != 1 || !evs[0].Deleted || !evs[0].Lifetime { |
| 262 | t.Fatalf("BYO refund: %+v, %v", evs, err) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestLifetimePriceFloorAndMemberGuards(t *testing.T) { |
| 267 | ts, srv := newTestServer(t) |
| 268 | srv.pay = &fakePay{checkoutURL: "https://pay.example/checkout"} |
| 269 | |
| 270 | mkUser := func(label string) (string, string) { |
| 271 | u, err := srv.db.createUser(label) |
| 272 | if err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| 279 | if err != nil || !ok { |
| 280 | t.Fatalf("claim: ok=%v err=%v", ok, err) |
| 281 | } |
| 282 | if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | return tk, u |
| 286 | } |
| 287 | admin, uidAdmin := mkUser("Root") |
| 288 | if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | tok, uid := mkUser("Bea") |
| 292 | tid, err := srv.db.createTier("Founder") |
| 293 | if err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | |
| 297 | post := func(path string, body map[string]any) int { |
| 298 | raw, _ := json.Marshal(body) |
| 299 | resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(string(raw))) |
| 300 | if err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | resp.Body.Close() |
| 304 | return resp.StatusCode |
| 305 | } |
| 306 | |
| 307 | // The β¬11 floor on lifetime pricing; a valid once price sticks. |
| 308 | if code := post(fmt.Sprintf("/api/tiers/%s/price", tid), map[string]any{ |
| 309 | "token": admin, "cents": 500, "currency": "eur", "interval": "once"}); code != http.StatusBadRequest { |
| 310 | t.Errorf("β¬5 lifetime accepted: %d", code) |
| 311 | } |
| 312 | if code := post(fmt.Sprintf("/api/tiers/%s/price", tid), map[string]any{ |
| 313 | "token": admin, "cents": 11000, "currency": "eur", "interval": "once", "provider_ref": "price_once"}); code != http.StatusOK { |
| 314 | t.Errorf("β¬110 lifetime rejected: %d", code) |
| 315 | } |
| 316 | |
| 317 | // A lifetime holder gets honest answers from the renewal-shaped verbs. |
| 318 | now := time.Now().Unix() |
| 319 | if err := srv.applyWebhookEvent(context.Background(), lifetimeGrant(uid, tid, now), now); err != nil { |
| 320 | t.Fatal(err) |
| 321 | } |
| 322 | for _, path := range []string{"cancel-subscription", "resume", "portal"} { |
| 323 | if code := post(fmt.Sprintf("/api/tiers/%s/%s", tid, path), map[string]any{"token": tok}); code != http.StatusBadRequest { |
| 324 | t.Errorf("%s on a lifetime purchase = %d, want 400", path, code) |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // TestOnceFeeMatchesPercent pins feePermille to applicationFeePercent β the |
| 330 | // string feeds Stripe's subscription form, the integer feeds payment-mode |
| 331 | // money math, and they must describe the same rate. |
| 332 | func TestOnceFeeMatchesPercent(t *testing.T) { |
| 333 | pct, err := strconv.ParseFloat(applicationFeePercent, 64) |
| 334 | if err != nil { |
| 335 | t.Fatalf("applicationFeePercent %q: %v", applicationFeePercent, err) |
| 336 | } |
| 337 | if got := float64(feePermille) / 10; got != pct { |
| 338 | t.Errorf("feePermille %d = %.2f%%, applicationFeePercent = %s", feePermille, got, applicationFeePercent) |
| 339 | } |
| 340 | // Floor rounding favours the admin: 5.5% of 999 is 54.945 β 54. |
| 341 | if got := onceFeeMinor(999); got != 54 { |
| 342 | t.Errorf("onceFeeMinor(999) = %d, want 54", got) |
| 343 | } |
| 344 | } |
| 345 | |