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(); $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'); }); it('does not fold a module Product adoption into the created/minted count', function () { // A module's prices are all genuinely new, and its Product exists at // Stripe already — unlinked from our own register, the same shape as a // family's orphaned Product one level up (AddonPrices::product() asks // AdoptStripeProduct exactly as the family loop does). The difference that // makes this dangerous: syncModules() below counts a module's PRICES as // intents, but it has never counted a module's PRODUCT as one — adopted or // minted, that Product prints no line and increments nothing — so // adopting it here must neither inflate "adopted" nor shrink "created". $this->stripe->plantProduct('prod_priority_support_orphan', 'Priority Support', [ 'addon' => 'priority_support', ]); // Computed from the catalogue rather than hardcoded, the way // ReverseChargePriceTest sizes a rate-change sweep: one Product per family, // two treatments per priced row, and monthly+yearly at both treatments for // every module — all of it newly minted here, nothing pre-existing to // adopt except the one Product just planted above. $modules = count(array_merge(array_keys((array) config('provisioning.addons')), ['storage'])); $expectedCreated = PlanFamily::query()->count() + PlanPrice::query()->count() * 2 + $modules * 4; $this->artisan('stripe:sync-catalogue') ->expectsOutputToContain("{$expectedCreated} object(s) created, 0 adopted in Stripe.") ->assertSuccessful(); // And the orphan really was taken over rather than merely ignored — the // Product this test is about, not a no-op that would pass it by accident. expect(StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->value('stripe_product_id'))->toBe('prod_priority_support_orphan'); });