CluPilotCloud/tests/Feature/Billing/StripeProductAdoptionTest.php

185 lines
8.2 KiB
PHP

<?php
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
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();
});
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');
});