set('services.stripe.secret', 'sk_test_plan_task_one'); }); it('sends a different key once the metadata changes', function () { Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]); $client = new HttpStripeClient; $spoken = 'clupilot-addon-price-priority_support-month-3480-EUR'; // The call as it stood before 9da1358, and the call after it: same money, // same interval, one metadata field more. $client->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support'], $spoken); $client->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], $spoken); $sent = collect(Http::recorded()) ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) ->all(); expect($sent[0])->toStartWith($spoken) ->and($sent[1])->toStartWith($spoken) ->and($sent[1])->not->toBe($sent[0]); }); it('sends the same key for the very same call', function () { Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]); $client = new HttpStripeClient; foreach ([1, 2] as $ignored) { $client->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support'], 'clupilot-addon-price'); } $sent = collect(Http::recorded()) ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) ->unique() ->all(); expect($sent)->toHaveCount(1); }); it('fingerprints the product call too, where the same trap was waiting', function () { Http::fake(['api.stripe.com/*' => Http::response(['id' => 'prod_x'])]); $client = new HttpStripeClient; $client->createProduct('Priority Support', ['addon' => 'priority_support'], 'clupilot-addon-product-x'); $client->createProduct('Priority Support', ['addon' => 'priority_support', 'sold_as' => 'entitlement'], 'clupilot-addon-product-x'); $sent = collect(Http::recorded()) ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) ->all(); expect($sent[1])->not->toBe($sent[0]); }); it('leaves the money calls their bare key, so Stripe still refuses a changed one', function () { Http::fake(['api.stripe.com/*' => Http::response(['id' => 'x'])]); $client = new HttpStripeClient; $client->refund('pi_1', 500, 'clupilot-refund-7'); $client->cancelSubscription('sub_1', 'at_period_end', 'clupilot-cancel-7'); $client->addSubscriptionItem('sub_1', 'price_1', 1, 'none', 'clupilot-item-7'); $sent = collect(Http::recorded()) ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) ->all(); expect($sent)->toBe(['clupilot-refund-7', 'clupilot-cancel-7', 'clupilot-item-7']); }); it('reproduces the refusal Stripe makes, which the fake used to swallow', function () { $fake = new FakeStripeClient; $fake->refund('pi_1', 500, 'clupilot-refund-7'); // Same key, different amount. Stripe answers 400; the fake said nothing and // replayed the first refund's id, which is how a test could pass over the // very failure that stopped production. expect(fn () => $fake->refund('pi_1', 900, 'clupilot-refund-7')) ->toThrow(RuntimeException::class, 'same parameters'); }); it('mints a second price rather than blocking when the metadata moved', function () { $fake = new FakeStripeClient; $first = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support'], 'clupilot-addon-price'); $second = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], 'clupilot-addon-price'); // Two objects, no exception. That the second one is not WANTED is the job of // AdoptStripePrice, not of the key — see StripePriceAdoptionTest. expect($second)->not->toBe($first); });