223 lines
9.3 KiB
PHP
223 lines
9.3 KiB
PHP
<?php
|
|
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\HttpStripeClient;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
/**
|
|
* A key says "I have already sent this call" — not "this is what the object is".
|
|
*
|
|
* The difference cost a day. A run created a Price at Stripe and died before the
|
|
* row was written; the next sweep sent the SAME key with an added metadata field
|
|
* and Stripe refused it for twenty-four hours — the sweep and, because
|
|
* AddonPrices::ensure() also runs inside a customer's module booking, the
|
|
* booking with it.
|
|
*
|
|
* So every parameter that goes on the wire goes into the key. On the catalogue
|
|
* calls only: for a refund or a second subscription item, Stripe's refusal is
|
|
* the thing standing between a customer and being charged twice.
|
|
*/
|
|
beforeEach(function () {
|
|
// The environment fallback is enough — HttpStripeClient reads the vault,
|
|
// and the vault falls back to config until a secret has been stored.
|
|
config()->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.
|
|
expect(collect($prices)->pluck('id')->all())->toBe(['price_monthly']);
|
|
});
|
|
|
|
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);
|
|
});
|