114 lines
4.7 KiB
PHP
114 lines
4.7 KiB
PHP
<?php
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\StripeAddonPrice;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\AddonPrices;
|
|
use App\Services\Billing\AdoptStripeProduct;
|
|
use App\Services\Billing\TaxTreatment;
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* A run that dies between Stripe creating a PRODUCT and us storing its id leaves
|
|
* an orphan too — and that one is worse than an orphaned Price.
|
|
*
|
|
* A second Product does not merely duplicate something. Every activePricesFor()
|
|
* afterwards is asked about the new Product, so the Price recognition built for
|
|
* exactly this failure goes blind for the whole family, and every orphaned Price
|
|
* on the first Product becomes unreachable. The guard disables itself through
|
|
* the gap beside it.
|
|
*
|
|
* Two things are deliberately unlike the Price side. There is no money gate: a
|
|
* Product carries no amount, so the metadata is the whole of the proof. And a
|
|
* duplicate is REPORTED, never deactivated — an archived Price is provably
|
|
* harmless because Stripe goes on billing subscriptions already on it, but
|
|
* deactivating a Product makes its Prices unsellable, and contracts can be
|
|
* running on those.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->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();
|
|
});
|