133 lines
5.4 KiB
PHP
133 lines
5.4 KiB
PHP
<?php
|
|
|
|
use App\Services\Billing\AdoptStripePrice;
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* A run that dies between Stripe's create and our insert leaves an orphan — and
|
|
* the next run must recognise it instead of minting a second live Price for the
|
|
* same money.
|
|
*
|
|
* On 2026-07-29 23:11 a sweep created price_1TygdEC7u8NpJ8pOt3nsoyYw
|
|
* (priority_support, 3480 EUR, monthly) and died before the row was written.
|
|
* The idempotency key covered the next twenty-four hours and then stopped
|
|
* covering anything; after that, the guard against a duplicate was nothing at
|
|
* all. This is that guard.
|
|
*
|
|
* What may be adopted is narrow on purpose: same money, same interval, same
|
|
* currency, proof in the metadata that the Price is ours, and no row claiming
|
|
* it already. Adopting somebody else's Price would be a worse failure than
|
|
* minting a second one, and adopting a Price at a DIFFERENT amount would move
|
|
* money — which nothing here is allowed to do.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->stripe = new FakeStripeClient;
|
|
app()->instance(StripeClient::class, $this->stripe);
|
|
});
|
|
|
|
/** The module metadata as AddonPrices sends it today. */
|
|
function moduleMetadata(string $treatment = 'domestic'): array
|
|
{
|
|
return ['addon' => 'priority_support', 'tax_treatment' => $treatment];
|
|
}
|
|
|
|
/** Ask the adoption step the question AddonPrices asks it. */
|
|
function adoptModulePrice(?array $metadata = null, ?callable $claimed = null): ?string
|
|
{
|
|
return app(AdoptStripePrice::class)(
|
|
productId: 'prod_support',
|
|
amountCents: 3480,
|
|
currency: 'EUR',
|
|
interval: 'month',
|
|
metadata: $metadata ?? moduleMetadata(),
|
|
identifying: ['addon'],
|
|
claimed: $claimed ?? fn (string $id) => false,
|
|
);
|
|
}
|
|
|
|
it('adopts the orphan of 2026-07-29 instead of minting a second price', function () {
|
|
// The state that morning: the Price exists at Stripe, carries the metadata
|
|
// of the code that made it — WITHOUT tax_treatment, which 9da1358 added
|
|
// afterwards — and no row of ours knows it.
|
|
$this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support',
|
|
3480, 'EUR', 'month', ['addon' => 'priority_support']);
|
|
|
|
expect(adoptModulePrice())->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw');
|
|
|
|
// Brought up to today's metadata rather than replaced: metadata is mutable
|
|
// at Stripe, the amount is not, which is the whole reason the format is no
|
|
// part of a Price's identity.
|
|
expect($this->stripe->metadataUpdates)->toBe([[
|
|
'price' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw',
|
|
'metadata' => moduleMetadata(),
|
|
]]);
|
|
});
|
|
|
|
it('leaves the metadata alone when it already says the right thing', function () {
|
|
$this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', moduleMetadata());
|
|
|
|
expect(adoptModulePrice())->toBe('price_ok')
|
|
->and($this->stripe->metadataUpdates)->toBe([]);
|
|
});
|
|
|
|
it('adopts nothing when the amount, currency or interval differ', function () {
|
|
$this->stripe->plantPrice('price_cheaper', 'prod_support', 2900, 'EUR', 'month', moduleMetadata());
|
|
$this->stripe->plantPrice('price_yearly', 'prod_support', 3480, 'EUR', 'year', moduleMetadata());
|
|
$this->stripe->plantPrice('price_dollars', 'prod_support', 3480, 'USD', 'month', moduleMetadata());
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
});
|
|
|
|
it('refuses a price nothing proves is ours, and says so', function () {
|
|
Log::spy();
|
|
|
|
// What a person clicking through Stripe's own dashboard leaves behind: the
|
|
// right money on our product, and not one word about what it is for.
|
|
$this->stripe->plantPrice('price_by_hand', 'prod_support', 3480, 'EUR', 'month', []);
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
|
|
Log::shouldHaveReceived('warning')->once();
|
|
});
|
|
|
|
it('passes silently over another of our own prices', function () {
|
|
Log::spy();
|
|
|
|
// At a VAT rate of nought both treatments are the same amount, so the
|
|
// reverse-charge Price sits at the domestic one's money — and contradicts on
|
|
// tax_treatment. That is not a mystery worth a warning; it is a Price of
|
|
// ours that is not the one being asked for.
|
|
$this->stripe->plantPrice('price_rc', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata('reverse_charge'));
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
|
|
Log::shouldNotHaveReceived('warning');
|
|
});
|
|
|
|
it('never hands out a price a row already claims', function () {
|
|
$this->stripe->plantPrice('price_taken', 'prod_support', 3480, 'EUR', 'month', moduleMetadata());
|
|
|
|
expect(adoptModulePrice(claimed: fn (string $id) => $id === 'price_taken'))->toBeNull();
|
|
});
|
|
|
|
it('adopts the oldest of several orphans and stops selling the rest', function () {
|
|
Log::spy();
|
|
|
|
$this->stripe->plantPrice('price_second', 'prod_support', 3480, 'EUR', 'month',
|
|
['addon' => 'priority_support'], created: 200);
|
|
$this->stripe->plantPrice('price_first', 'prod_support', 3480, 'EUR', 'month',
|
|
['addon' => 'priority_support'], created: 100);
|
|
$this->stripe->plantPrice('price_third', 'prod_support', 3480, 'EUR', 'month',
|
|
['addon' => 'priority_support'], created: 300);
|
|
|
|
// The oldest, because it is the one a lost row is likeliest to have been
|
|
// billing on.
|
|
expect(adoptModulePrice())->toBe('price_first')
|
|
->and($this->stripe->archived)->toBe(['price_second', 'price_third']);
|
|
|
|
Log::shouldHaveReceived('warning');
|
|
});
|