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); }); it('pages through every active price of a product', function () { Http::fake([ 'api.stripe.com/*' => Http::sequence() ->push([ 'data' => [ ['id' => 'price_a', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 100, 'recurring' => ['interval' => 'month'], 'metadata' => ['addon' => 'priority_support']], ['id' => 'price_b', 'unit_amount' => 41760, 'currency' => 'eur', 'created' => 101, 'recurring' => ['interval' => 'year'], 'metadata' => []], ], 'has_more' => true, ]) ->push([ 'data' => [ ['id' => 'price_c', 'unit_amount' => 2900, 'currency' => 'eur', 'created' => 102, 'recurring' => ['interval' => 'month'], 'metadata' => ['addon' => 'priority_support', 'tax_treatment' => 'reverse_charge']], ], 'has_more' => false, ]), ]); $prices = (new HttpStripeClient)->activePricesFor('prod_1'); expect($prices)->toHaveCount(3) ->and($prices[0])->toBe([ 'id' => 'price_a', 'unit_amount' => 3480, // Upper case, because that is how our own tables hold it and the // comparison in AdoptStripePrice must not have to remember which // side is which. 'currency' => 'EUR', 'interval' => 'month', 'created' => 100, 'metadata' => ['addon' => 'priority_support'], ]) ->and($prices[2]['id'])->toBe('price_c'); // The second page has to be asked for, or this reintroduces the very gap it // exists to close — a family product accumulates prices across versions, // terms, treatments and every rate change. Http::assertSent(fn ($request) => str_contains($request->url(), 'starting_after=price_b')); // Archived prices are none of our business here: we are looking for // something to SELL on. Http::assertSent(fn ($request) => str_contains($request->url(), 'active=true')); }); it('skips a price it could not have created itself, so the money gate never trusts a partial recurrence', function () { Http::fake([ 'api.stripe.com/*' => Http::response([ 'data' => [ ['id' => 'price_monthly', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 100, 'recurring' => ['interval' => 'month'], 'metadata' => ['addon' => 'priority_support']], ['id' => 'price_quarterly', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 101, 'recurring' => ['interval' => 'month', 'interval_count' => 3], 'metadata' => ['addon' => 'priority_support']], ['id' => 'price_metered', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 102, 'recurring' => ['interval' => 'month', 'usage_type' => 'metered'], 'metadata' => ['addon' => 'priority_support']], ], 'has_more' => false, ]), ]); $prices = (new HttpStripeClient)->activePricesFor('prod_1'); // A hand-duplicated dashboard price keeps our metadata, so nothing // downstream could tell it apart, and the module would bill quarterly. // // None of the three planted prices carries `transform_quantity` or // `billing_scheme` at all, and the ordinary one still comes back — so this // also holds the other direction of the next test's filter: an absent key is // Stripe's default, not a reason to reject. expect(collect($prices)->pluck('id')->all())->toBe(['price_monthly']); }); it('skips a price that would charge our figure for the wrong quantity', function () { Http::fake([ 'api.stripe.com/*' => Http::response([ 'data' => [ // Spelled out as Stripe actually sends them for an ordinary // price: transform_quantity null, billing_scheme per_unit. ['id' => 'price_ordinary', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 100, 'recurring' => ['interval' => 'month'], 'transform_quantity' => null, 'billing_scheme' => 'per_unit', 'metadata' => ['addon' => 'priority_support']], ['id' => 'price_divided', 'unit_amount' => 3480, 'currency' => 'eur', 'created' => 101, 'recurring' => ['interval' => 'month'], 'transform_quantity' => ['divide_by' => 10, 'round' => 'up'], 'billing_scheme' => 'per_unit', 'metadata' => ['addon' => 'priority_support']], ['id' => 'price_tiered', 'unit_amount' => null, 'currency' => 'eur', 'created' => 102, 'recurring' => ['interval' => 'month'], 'billing_scheme' => 'tiered', 'metadata' => ['addon' => 'priority_support']], ], 'has_more' => false, ]), ]); $prices = (new HttpStripeClient)->activePricesFor('prod_1'); // The concrete harm: modules are billed BY quantity — SyncStripeAddonItems // sums a pack into ONE item at quantity n — so price_divided is our exact // figure, on our Product, carrying our `addon` key, and adoption would take // it. A customer holding three would then be charged ceil(3/10) = 1. // // price_tiered is refused by name rather than by luck: Stripe reports // unit_amount null for a tiered price, activePricesFor() casts that to 0, and // the amount match alone only rejects it while the caller's own figure is not // 0 — which PlanPrices::ensure(), unlike AddonPrices::ensure(), does not // guarantee. expect(collect($prices)->pluck('id')->all())->toBe(['price_ordinary']); }); it('writes metadata onto a price that already exists', function () { Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_a'])]); (new HttpStripeClient)->updatePriceMetadata('price_a', ['addon' => 'priority_support']); Http::assertSent(fn ($request) => $request->url() === 'https://api.stripe.com/v1/prices/price_a' && $request['metadata[addon]'] === 'priority_support'); }); it('lets the fake answer with the prices it holds, minus the archived ones', function () { $fake = new FakeStripeClient; $kept = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support']); $gone = $fake->createPrice('prod_1', 2900, 'EUR', 'month', ['addon' => 'priority_support']); $other = $fake->createPrice('prod_2', 3480, 'EUR', 'month', []); $fake->archivePrice($gone); $fake->plantPrice('price_orphan', 'prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support'], created: 0); $found = collect($fake->activePricesFor('prod_1'))->pluck('id')->all(); expect($found)->toContain($kept, 'price_orphan') ->and($found)->not->toContain($gone, $other); });