CluPilotCloud/tests/Feature/Billing/StripePriceAdoptionTest.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');
});