stripe = new FakeStripeClient; app()->instance(StripeClient::class, $this->stripe); app(Kernel::class)->call('stripe:sync-catalogue'); $this->product = (string) StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->value('stripe_product_id'); $this->known = (string) StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->where('interval', 'month') ->where('reverse_charge', false) ->value('stripe_price_id'); $this->stripe->plantPrice('price_orphan', $this->product, 9900, 'EUR', 'month', []); }); it('reports the orphan and leaves it alone', function () { $this->artisan('stripe:sweep-orphan-prices') ->expectsOutputToContain('price_orphan') ->assertSuccessful(); expect($this->stripe->archived)->toBe([]); }); it('never reports a price a row knows', function () { $this->artisan('stripe:sweep-orphan-prices') ->doesntExpectOutputToContain($this->known) ->assertSuccessful(); }); it('sees the plan side too: a family product orphan is reported, a known plan price never', function () { // Every other test in this file plants its orphan under the MODULE Product, // so both halves of the plan side went untested: the PlanFamily half of // products() and the StripePlanPrice half of the $known set. Blind to // either, the command misses the LARGER half — a family Product collects a // Price per version, term, treatment and rate change, a module Product four. // // Both assertions sit in one test on purpose: each fails for one of the two // lines. Drop the PlanFamily pluck from products() and the family Product is // never visited, so the orphan below goes unreported. Drop the // StripePlanPrice pluck from $known and the row's own Price becomes an // orphan the command names. $row = PlanPrice::query()->firstOrFail(); $familyProduct = (string) $row->version->family->stripe_product_id; $knownPlanPrice = (string) StripePlanPrice::query() ->where('plan_price_id', $row->id) ->where('reverse_charge', false) ->value('stripe_price_id'); // At the family's Product and at an amount no row of ours holds — what the // sync leaves behind when it dies between Stripe's create and our insert, // seen from the sweep's side. $this->stripe->plantPrice('price_family_orphan', $familyProduct, 12300, (string) $row->currency, 'month', []); $this->artisan('stripe:sweep-orphan-prices') ->expectsOutputToContain('price_family_orphan') ->doesntExpectOutputToContain($knownPlanPrice) ->assertSuccessful(); expect($familyProduct)->not->toBe('') ->and($knownPlanPrice)->not->toBe('') // Nothing was touched: this is the reporting path, not --archive. ->and($this->stripe->archived)->toBe([]); }); it('stops selling the orphan when asked to', function () { $this->artisan('stripe:sweep-orphan-prices', ['--archive' => true]) ->assertSuccessful(); // Archiving is harmless for the same reason it always is here: Stripe goes // on billing every subscription already on an archived Price, it merely // stops being sold. expect($this->stripe->archived)->toBe(['price_orphan']); }); it('touches nothing on a dry run, even when asked to archive', function () { $this->artisan('stripe:sweep-orphan-prices', ['--archive' => true, '--dry-run' => true]) ->expectsOutputToContain('price_orphan') ->assertSuccessful(); expect($this->stripe->archived)->toBe([]); }); it('fails cleanly on a dry run when Stripe is not configured, rather than throwing', function () { // Unlike stripe:sync-catalogue, this command has no local-only path: the // report itself is a live activePricesFor() call, dry-run or not. A guard // that only fired outside --dry-run would let this fall straight into // HttpStripeClient's own "not configured" exception instead of the clean // error below. $this->stripe->configured = false; $this->artisan('stripe:sweep-orphan-prices', ['--dry-run' => true]) ->expectsOutputToContain('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.') ->assertFailed(); });