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'); });