|
15 | 15 | beforeEach(function () { |
16 | 16 | config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); |
17 | 17 |
|
18 | | - Plan::factory()->create([ |
19 | | - 'slug' => Slug::Starter, |
20 | | - 'name' => 'Starter', |
21 | | - 'stripe_monthly_price_id' => 'price_starter_monthly', |
22 | | - 'stripe_yearly_price_id' => 'price_starter_yearly', |
23 | | - ]); |
24 | | - Plan::factory()->create([ |
25 | | - 'slug' => Slug::Pro, |
26 | | - 'name' => 'Pro', |
27 | | - 'stripe_monthly_price_id' => 'price_pro_monthly', |
28 | | - 'stripe_yearly_price_id' => 'price_pro_yearly', |
| 18 | + // Use the seeded Workspace plan; set deterministic price ids so the |
| 19 | + // assertions don't depend on `.env.testing`. |
| 20 | + $this->plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); |
| 21 | + $this->plan->update([ |
| 22 | + 'stripe_monthly_price_id' => 'price_workspace_monthly', |
| 23 | + 'stripe_yearly_price_id' => 'price_workspace_yearly', |
29 | 24 | ]); |
30 | 25 |
|
31 | 26 | $this->account = Account::factory()->create(['stripe_id' => 'cus_test123']); |
|
51 | 46 | }); |
52 | 47 |
|
53 | 48 | test('subscription created clears the generic trial_ends_at on the account', function () { |
54 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
55 | 49 | $this->account->update(['trial_ends_at' => now()->addDays(3)]); |
56 | 50 |
|
57 | 51 | $this->listener->handle(new WebhookReceived([ |
|
60 | 54 | 'customer' => 'cus_test123', |
61 | 55 | 'id' => 'sub_123', |
62 | 56 | 'status' => 'active', |
63 | | - 'items' => ['data' => [['price' => ['id' => $starter->stripe_monthly_price_id]]]], |
| 57 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
64 | 58 | ]], |
65 | 59 | ])); |
66 | 60 |
|
|
184 | 178 | }); |
185 | 179 |
|
186 | 180 | test('subscription deleted dispatches TrackBilling with subscription.cancelled event', function () { |
187 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
188 | | - $this->account->update(['plan_id' => $starter->id]); |
| 181 | + $this->account->update(['plan_id' => $this->plan->id]); |
189 | 182 |
|
190 | 183 | Bus::fake([TrackBilling::class]); |
191 | 184 |
|
|
236 | 229 | }); |
237 | 230 |
|
238 | 231 | // ======================================== |
239 | | -// Plan sync — domain logic |
| 232 | +// Plan mapping by Stripe price id |
240 | 233 | // ======================================== |
241 | | -// |
242 | | -// Tests below override the seeded plans' Stripe price ids with deterministic |
243 | | -// values so the assertions don't depend on `.env.testing` having |
244 | | -// `STRIPE_*_MONTHLY/YEARLY` set. |
245 | | - |
246 | | -beforeEach(function () { |
247 | | - Plan::query()->where('slug', 'starter')->update([ |
248 | | - 'stripe_monthly_price_id' => 'price_starter_monthly', |
249 | | - 'stripe_yearly_price_id' => 'price_starter_yearly', |
250 | | - ]); |
251 | | - Plan::query()->where('slug', 'pro')->update([ |
252 | | - 'stripe_monthly_price_id' => 'price_pro_monthly', |
253 | | - 'stripe_yearly_price_id' => 'price_pro_yearly', |
254 | | - ]); |
255 | | -}); |
256 | 234 |
|
257 | | -test('subscription updated swaps account plan_id when price matches a different plan', function () { |
258 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
259 | | - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); |
260 | | - |
261 | | - $this->account->update(['plan_id' => $starter->id]); |
| 235 | +test('subscription created syncs the plan from the price id on first activation', function () { |
| 236 | + $this->account->update(['plan_id' => null]); |
262 | 237 |
|
263 | 238 | $this->listener->handle(new WebhookReceived([ |
264 | | - 'type' => 'customer.subscription.updated', |
| 239 | + 'type' => 'customer.subscription.created', |
265 | 240 | 'data' => ['object' => [ |
266 | 241 | 'customer' => 'cus_test123', |
267 | | - 'items' => ['data' => [ |
268 | | - ['price' => ['id' => 'price_pro_monthly']], |
269 | | - ]], |
| 242 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
270 | 243 | ]], |
271 | 244 | ])); |
272 | 245 |
|
273 | | - expect($this->account->fresh()->plan_id)->toBe($pro->id); |
| 246 | + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); |
274 | 247 | }); |
275 | 248 |
|
276 | | -test('subscription updated leaves plan_id alone when price already matches current plan', function () { |
277 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
278 | | - $this->account->update(['plan_id' => $starter->id]); |
| 249 | +test('subscription updated maps the plan by its monthly price id', function () { |
| 250 | + $this->account->update(['plan_id' => null]); |
279 | 251 |
|
280 | 252 | $this->listener->handle(new WebhookReceived([ |
281 | 253 | 'type' => 'customer.subscription.updated', |
282 | 254 | 'data' => ['object' => [ |
283 | 255 | 'customer' => 'cus_test123', |
284 | | - 'items' => ['data' => [ |
285 | | - ['price' => ['id' => 'price_starter_monthly']], |
286 | | - ]], |
| 256 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
287 | 257 | ]], |
288 | 258 | ])); |
289 | 259 |
|
290 | | - expect($this->account->fresh()->plan_id)->toBe($starter->id); |
| 260 | + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); |
291 | 261 | }); |
292 | 262 |
|
293 | | -test('subscription updated ignores unknown price ids without erroring', function () { |
294 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
295 | | - $this->account->update(['plan_id' => $starter->id]); |
| 263 | +test('subscription updated maps the plan by its yearly price id too', function () { |
| 264 | + $this->account->update(['plan_id' => null]); |
296 | 265 |
|
297 | 266 | $this->listener->handle(new WebhookReceived([ |
298 | 267 | 'type' => 'customer.subscription.updated', |
299 | 268 | 'data' => ['object' => [ |
300 | 269 | 'customer' => 'cus_test123', |
301 | | - 'items' => ['data' => [ |
302 | | - ['price' => ['id' => 'price_unknown_xyz']], |
303 | | - ]], |
| 270 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_yearly']]]], |
304 | 271 | ]], |
305 | 272 | ])); |
306 | 273 |
|
307 | | - expect($this->account->fresh()->plan_id)->toBe($starter->id); |
| 274 | + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); |
308 | 275 | }); |
309 | 276 |
|
310 | | -test('subscription updated matches yearly price ids too', function () { |
311 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
312 | | - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); |
313 | | - |
314 | | - $this->account->update(['plan_id' => $starter->id]); |
| 277 | +test('subscription updated leaves plan_id alone when the price already matches', function () { |
| 278 | + $this->account->update(['plan_id' => $this->plan->id]); |
315 | 279 |
|
316 | 280 | $this->listener->handle(new WebhookReceived([ |
317 | 281 | 'type' => 'customer.subscription.updated', |
318 | 282 | 'data' => ['object' => [ |
319 | 283 | 'customer' => 'cus_test123', |
320 | | - 'items' => ['data' => [ |
321 | | - ['price' => ['id' => 'price_pro_yearly']], |
322 | | - ]], |
| 284 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
323 | 285 | ]], |
324 | 286 | ])); |
325 | 287 |
|
326 | | - expect($this->account->fresh()->plan_id)->toBe($pro->id); |
| 288 | + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); |
327 | 289 | }); |
328 | 290 |
|
329 | | -test('subscription created syncs plan from price ids on first activation', function () { |
330 | | - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); |
331 | | - $this->account->update(['plan_id' => null]); |
| 291 | +test('subscription updated ignores unknown price ids without erroring', function () { |
| 292 | + $this->account->update(['plan_id' => $this->plan->id]); |
332 | 293 |
|
333 | 294 | $this->listener->handle(new WebhookReceived([ |
334 | | - 'type' => 'customer.subscription.created', |
| 295 | + 'type' => 'customer.subscription.updated', |
335 | 296 | 'data' => ['object' => [ |
336 | 297 | 'customer' => 'cus_test123', |
337 | | - 'items' => ['data' => [ |
338 | | - ['price' => ['id' => 'price_pro_monthly']], |
339 | | - ]], |
| 298 | + 'items' => ['data' => [['price' => ['id' => 'price_unknown_xyz']]]], |
340 | 299 | ]], |
341 | 300 | ])); |
342 | 301 |
|
343 | | - expect($this->account->fresh()->plan_id)->toBe($pro->id); |
| 302 | + expect($this->account->fresh()->plan_id)->toBe($this->plan->id); |
344 | 303 | }); |
345 | 304 |
|
346 | 305 | test('subscription deleted clears the account plan_id', function () { |
347 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
348 | | - $this->account->update(['plan_id' => $starter->id]); |
| 306 | + $this->account->update(['plan_id' => $this->plan->id]); |
349 | 307 |
|
350 | 308 | $this->listener->handle(new WebhookReceived([ |
351 | 309 | 'type' => 'customer.subscription.deleted', |
|
377 | 335 | // ======================================== |
378 | 336 |
|
379 | 337 | test('subscription updated forwards the previous plan name to TrackBilling', function () { |
380 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
381 | | - $this->account->update(['plan_id' => $starter->id]); |
| 338 | + $this->account->update(['plan_id' => $this->plan->id]); |
382 | 339 |
|
383 | 340 | Bus::fake([TrackBilling::class]); |
384 | 341 |
|
385 | 342 | $this->listener->handle(new WebhookReceived([ |
386 | 343 | 'type' => 'customer.subscription.updated', |
387 | 344 | 'data' => ['object' => [ |
388 | 345 | 'customer' => 'cus_test123', |
389 | | - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], |
| 346 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
390 | 347 | ]], |
391 | 348 | ])); |
392 | 349 |
|
393 | 350 | Bus::assertDispatched( |
394 | 351 | TrackBilling::class, |
395 | | - fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $starter->name, |
| 352 | + fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $this->plan->name, |
396 | 353 | ); |
397 | 354 | }); |
398 | 355 |
|
399 | 356 | test('subscription deleted forwards the previous plan name to TrackBilling', function () { |
400 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
401 | | - $this->account->update(['plan_id' => $starter->id]); |
| 357 | + $this->account->update(['plan_id' => $this->plan->id]); |
402 | 358 |
|
403 | 359 | Bus::fake([TrackBilling::class]); |
404 | 360 |
|
|
409 | 365 |
|
410 | 366 | Bus::assertDispatched( |
411 | 367 | TrackBilling::class, |
412 | | - fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $starter->name, |
| 368 | + fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $this->plan->name, |
413 | 369 | ); |
414 | 370 | }); |
415 | 371 |
|
|
422 | 378 | 'type' => 'customer.subscription.created', |
423 | 379 | 'data' => ['object' => [ |
424 | 380 | 'customer' => 'cus_test123', |
425 | | - 'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]], |
| 381 | + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], |
426 | 382 | ]], |
427 | 383 | ])); |
428 | 384 |
|
|
431 | 387 | fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null, |
432 | 388 | ); |
433 | 389 | }); |
434 | | - |
435 | | -// ======================================== |
436 | | -// Plan mapping by Stripe price id |
437 | | -// ======================================== |
438 | | - |
439 | | -test('subscription updated maps the plan by price id', function () { |
440 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
441 | | - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); |
442 | | - |
443 | | - $this->account->update(['plan_id' => $starter->id]); |
444 | | - |
445 | | - $this->listener->handle(new WebhookReceived([ |
446 | | - 'type' => 'customer.subscription.updated', |
447 | | - 'data' => ['object' => [ |
448 | | - 'customer' => 'cus_test123', |
449 | | - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], |
450 | | - ]], |
451 | | - ])); |
452 | | - |
453 | | - expect($this->account->fresh()->plan_id)->toBe($pro->id); |
454 | | -}); |
455 | | - |
456 | | -test('subscription updated maps an archived legacy plan by price id', function () { |
457 | | - $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); |
458 | | - $pro->update(['is_archived' => true]); |
459 | | - |
460 | | - $this->listener->handle(new WebhookReceived([ |
461 | | - 'type' => 'customer.subscription.updated', |
462 | | - 'data' => ['object' => [ |
463 | | - 'customer' => 'cus_test123', |
464 | | - 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], |
465 | | - ]], |
466 | | - ])); |
467 | | - |
468 | | - expect($this->account->fresh()->plan_id)->toBe($pro->id); |
469 | | -}); |
470 | | - |
471 | | -test('subscription deleted clears the plan_id', function () { |
472 | | - $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); |
473 | | - $this->account->update(['plan_id' => $starter->id]); |
474 | | - |
475 | | - $this->listener->handle(new WebhookReceived([ |
476 | | - 'type' => 'customer.subscription.deleted', |
477 | | - 'data' => ['object' => ['customer' => 'cus_test123']], |
478 | | - ])); |
479 | | - |
480 | | - expect($this->account->fresh()->plan_id)->toBeNull(); |
481 | | -}); |
0 commit comments