stripe = new FakeStripeClient; app()->instance(StripeClient::class, $this->stripe); }); it('adopts an orphaned family product instead of minting a second one', function () { // A run that got as far as creating the Product and died before storing its // id: Stripe has it, plan_families does not. $family = PlanFamily::query()->orderBy('tier')->firstOrFail(); $family->update(['stripe_product_id' => null]); $this->stripe->plantProduct('prod_orphan_family', $family->name, [ 'plan_family' => $family->key, 'plan_family_id' => (string) $family->id, ]); app(Kernel::class)->call('stripe:sync-catalogue'); // Counting every product would prove nothing — the same run mints one for // each of the OTHER families. What must hold is that this family's id is // carried by exactly one product, and that it is the planted one. expect($family->refresh()->stripe_product_id)->toBe('prod_orphan_family') ->and(collect($this->stripe->products) ->filter(fn (array $p) => ($p['metadata']['plan_family_id'] ?? null) === (string) $family->id) ->keys()->all())->toBe(['prod_orphan_family']); }); it('adopts an orphaned module product instead of minting a second one', function () { $this->stripe->plantProduct('prod_orphan_module', 'Priority Support', [ 'addon' => 'priority_support', ]); $before = count($this->stripe->products); app(AddonPrices::class)->ensure( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); expect(StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->value('stripe_product_id'))->toBe('prod_orphan_module') ->and(count($this->stripe->products))->toBe($before); }); it('adopts nothing a product does not prove about itself', function () { // What an operator selling something else through the same Stripe account // leaves lying around. Silent, unlike the Price side: we list every product // on the account, so warning about each foreign one would fire on every run // for as long as it exists. Log::spy(); $this->stripe->plantProduct('prod_stranger', 'Etwas ganz anderes', []); expect(app(AdoptStripeProduct::class)( ['addon' => 'priority_support'], ['addon'], ))->toBeNull(); Log::shouldNotHaveReceived('warning'); }); it('adopts nothing that contradicts what we would have written', function () { $this->stripe->plantProduct('prod_other_module', 'Collabora Pro', ['addon' => 'collabora_pro']); expect(app(AdoptStripeProduct::class)( ['addon' => 'priority_support'], ['addon'], ))->toBeNull(); }); it('takes the oldest of two matching products and leaves the other alone', function () { Log::spy(); $this->stripe->plantProduct('prod_second', 'Priority Support', ['addon' => 'priority_support'], created: 200); $this->stripe->plantProduct('prod_first', 'Priority Support', ['addon' => 'priority_support'], created: 100); $adopt = app(AdoptStripeProduct::class); expect($adopt(['addon' => 'priority_support'], ['addon']))->toBe('prod_first') // NOT deactivated. Its prices would become unsellable, and a contract // can be running on one of them — the asymmetry with archivePrice(). ->and($adopt->duplicates)->toBe(['prod_second']); Log::shouldHaveReceived('warning')->once(); }); it('says it adopted, not that it created', function () { // The one signal a human reads after an incident. The count is taken before // ensure() runs, so without this the run reports objects created in Stripe // when it made none — and telling those two apart is the whole point of the // recognition step. app(Kernel::class)->call('stripe:sync-catalogue'); $row = PlanPrice::query()->firstOrFail(); $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); $orphan = (string) StripePlanPrice::query() ->where('plan_price_id', $row->id)->where('reverse_charge', false) ->value('stripe_price_id'); StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete(); $this->stripe->keys = []; $this->artisan('stripe:sync-catalogue') ->expectsOutputToContain('1 adopted') ->assertSuccessful(); }); it('names a duplicate product in its report, without touching it', function () { // Nothing has been synced yet, same as the orphan test above — the family // has no product of its own, and two stray ones claiming to be it are the // whole of what Stripe has for it. $family = PlanFamily::query()->orderBy('tier')->firstOrFail(); $family->update(['stripe_product_id' => null]); $metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id]; $this->stripe->plantProduct('prod_first', $family->name, $metadata, created: 100); $this->stripe->plantProduct('prod_second', $family->name, $metadata, created: 200); $this->artisan('stripe:sync-catalogue') ->expectsOutputToContain('prod_second') ->assertSuccessful(); expect($family->refresh()->stripe_product_id)->toBe('prod_first'); });