CluPilotCloud/tests/Feature/Billing/StripePriceAdoptionTest.php

404 lines
19 KiB
PHP

<?php
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\AdoptStripePrice;
use App\Services\Billing\PlanPrices;
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's create and our insert leaves an orphan — and
* the next run must recognise it instead of minting a second live Price for the
* same money.
*
* On 2026-07-29 23:11 a sweep created price_1TygdEC7u8NpJ8pOt3nsoyYw
* (priority_support, 3480 EUR, monthly) and died before the row was written.
* The idempotency key covered the next twenty-four hours and then stopped
* covering anything; after that, the guard against a duplicate was nothing at
* all. This is that guard.
*
* What may be adopted is narrow on purpose: same money, same interval, same
* currency, proof in the metadata that the Price is ours, and no row claiming
* it already. Adopting somebody else's Price would be a worse failure than
* minting a second one, and adopting a Price at a DIFFERENT amount would move
* money — which nothing here is allowed to do.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
});
/** The module metadata as AddonPrices sends it today. */
function moduleMetadata(string $treatment = 'domestic'): array
{
return ['addon' => 'priority_support', 'tax_treatment' => $treatment];
}
/** Ask the adoption step the question AddonPrices asks it. */
function adoptModulePrice(?array $metadata = null, ?callable $claimed = null): ?string
{
return app(AdoptStripePrice::class)(
productId: 'prod_support',
amountCents: 3480,
currency: 'EUR',
interval: 'month',
metadata: $metadata ?? moduleMetadata(),
identifying: ['addon'],
claimed: $claimed ?? fn (string $id) => false,
);
}
it('adopts the orphan of 2026-07-29 instead of minting a second price', function () {
// The state that morning: the Price exists at Stripe, carries the metadata
// of the code that made it — WITHOUT tax_treatment, which 9da1358 added
// afterwards — and no row of ours knows it.
$this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support',
3480, 'EUR', 'month', ['addon' => 'priority_support']);
expect(adoptModulePrice())->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw');
// Brought up to today's metadata rather than replaced: metadata is mutable
// at Stripe, the amount is not, which is the whole reason the format is no
// part of a Price's identity.
expect($this->stripe->metadataUpdates)->toBe([[
'price' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw',
'metadata' => moduleMetadata(),
]]);
});
it('leaves the metadata alone when it already says the right thing', function () {
$this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', moduleMetadata());
expect(adoptModulePrice())->toBe('price_ok')
->and($this->stripe->metadataUpdates)->toBe([]);
});
it('leaves the metadata alone when Stripe merged it in a different order plus a key of its own', function () {
// plantPrice() stores our own array in our own order, so the test above
// cannot catch an order- or merge-sensitive comparison: Stripe returns
// metadata as the result of a MERGE, never a replace, in whatever key
// order it likes — and a write never removes a key it did not send.
$this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', [
'tax_treatment' => 'domestic',
'addon' => 'priority_support',
'internal_note' => 'added by hand in the Stripe dashboard',
]);
expect(adoptModulePrice())->toBe('price_ok')
->and($this->stripe->metadataUpdates)->toBe([]);
});
it('adopts nothing when the amount, currency or interval differ', function () {
$this->stripe->plantPrice('price_cheaper', 'prod_support', 2900, 'EUR', 'month', moduleMetadata());
$this->stripe->plantPrice('price_yearly', 'prod_support', 3480, 'EUR', 'year', moduleMetadata());
$this->stripe->plantPrice('price_dollars', 'prod_support', 3480, 'USD', 'month', moduleMetadata());
expect(adoptModulePrice())->toBeNull();
});
it('refuses a price nothing proves is ours, and says so', function () {
Log::spy();
// What a person clicking through Stripe's own dashboard leaves behind: the
// right money on our product, and not one word about what it is for.
$this->stripe->plantPrice('price_by_hand', 'prod_support', 3480, 'EUR', 'month', []);
expect(adoptModulePrice())->toBeNull();
Log::shouldHaveReceived('warning')->once();
});
it('passes silently over another of our own prices', function () {
Log::spy();
// At a VAT rate of nought both treatments are the same amount, so the
// reverse-charge Price sits at the domestic one's money — and contradicts on
// tax_treatment. That is not a mystery worth a warning; it is a Price of
// ours that is not the one being asked for.
$this->stripe->plantPrice('price_rc', 'prod_support', 3480, 'EUR', 'month',
moduleMetadata('reverse_charge'));
expect(adoptModulePrice())->toBeNull();
Log::shouldNotHaveReceived('warning');
});
it('never hands out a price a row already claims', function () {
$this->stripe->plantPrice('price_taken', 'prod_support', 3480, 'EUR', 'month', moduleMetadata());
expect(adoptModulePrice(claimed: fn (string $id) => $id === 'price_taken'))->toBeNull();
});
it('adopts the oldest of several orphans and stops selling the rest', function () {
Log::spy();
$this->stripe->plantPrice('price_second', 'prod_support', 3480, 'EUR', 'month',
['addon' => 'priority_support'], created: 200);
$this->stripe->plantPrice('price_first', 'prod_support', 3480, 'EUR', 'month',
['addon' => 'priority_support'], created: 100);
$this->stripe->plantPrice('price_third', 'prod_support', 3480, 'EUR', 'month',
['addon' => 'priority_support'], created: 300);
// The oldest, because it is the one a lost row is likeliest to have been
// billing on.
expect(adoptModulePrice())->toBe('price_first')
->and($this->stripe->archived)->toBe(['price_second', 'price_third']);
Log::shouldHaveReceived('warning');
});
it('takes the orphan over instead of minting a second module price', function () {
// The product exists because a previous run got that far; the Price exists
// because the run that made it died before the insert.
$this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support',
3480, 'EUR', 'month', ['addon' => 'priority_support']);
StripeAddonPrice::query()->create([
'addon_key' => 'priority_support', 'reverse_charge' => false,
'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR',
'interval' => 'year', 'stripe_product_id' => 'prod_support',
'stripe_price_id' => 'price_yearly_already_known',
]);
$before = count($this->stripe->prices);
$id = app(AddonPrices::class)->ensure(
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
);
expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw')
// Nothing new at Stripe: the orphan was taken over, not replaced.
->and(count($this->stripe->prices))->toBe($before)
->and(StripeAddonPrice::query()
->where('addon_key', 'priority_support')
->where('interval', 'month')
->where('reverse_charge', false)
->value('stripe_price_id'))->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw');
});
it('does not block a booking because the metadata format moved', function () {
// 2026-07-29, exactly: orphan with the old metadata, code with the new. This
// is the call that answered HTTP 400 for twenty-four hours.
$this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support',
3480, 'EUR', 'month', ['addon' => 'priority_support']);
StripeAddonPrice::query()->create([
'addon_key' => 'priority_support', 'reverse_charge' => false,
'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR',
'interval' => 'year', 'stripe_product_id' => 'prod_support',
'stripe_price_id' => 'price_yearly_already_known',
]);
$id = app(AddonPrices::class)->ensure(
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
);
expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw')
->and($this->stripe->metadataUpdates)->toHaveCount(1);
});
it('leaves a frozen booking on the price it was sold at', function () {
// Seeds the product id AddonPrices::product() will find and reuse — same
// reason as the first two tests in this file. Without it, the sync below
// mints its own Stripe Product with a generated id, and the orphan planted
// further down (deliberately at the literal 'prod_support' Stripe uses
// throughout this file) would sit on a product nothing ever asks about,
// making it inert rather than the competing figure the test needs.
StripeAddonPrice::query()->create([
'addon_key' => 'priority_support', 'reverse_charge' => false,
'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR',
'interval' => 'year', 'stripe_product_id' => 'prod_support',
'stripe_price_id' => 'price_yearly_already_known',
]);
app(Kernel::class)->call('stripe:sync-catalogue');
$sold = app(AddonPrices::class)->liveFor(
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
);
$subscription = Subscription::factory()->plan('team')->create();
$booking = SubscriptionAddon::query()->create([
'subscription_id' => $subscription->id,
'addon_key' => 'priority_support',
// Net, per month, per unit — frozen at booking. `currency` and
// `booked_at` are both NOT NULL (2026_07_26_060000), and `uuid` fills
// itself through the model's uniqueIds().
'price_cents' => 2900,
'currency' => 'EUR',
'quantity' => 1,
'booked_at' => now(),
'stripe_price_id' => $sold,
]);
// The catalogue moves, and somebody has already left an orphan at the new
// figure. Neither may reach a booking that is already frozen.
config()->set('provisioning.addons.priority_support.price_cents', 3900);
$this->stripe->plantPrice('price_orphan_new_figure', 'prod_support',
4680, 'EUR', 'month', ['addon' => 'priority_support']);
app(Kernel::class)->call('stripe:sync-catalogue');
// The `archived` assertion right below — the second of this chain — is
// this test's only adoption tooth. AdoptStripePrice archives a Price at
// Stripe when it treats one as a duplicate of another it adopted, and
// that is the one way it could reach past the figure it was asked for
// (4680, the new one) and take the frozen booking's own Price (3480) down
// with it as a false "duplicate".
//
// The three assertions after it read the SWEEP's own row-writing code,
// not adoption — AdoptStripePrice performs no database writes at all, so
// nothing it does, singly or in combination, can fail them. They earn
// their place anyway, because they are exactly what a defect in the
// sweep's OTHER two writers would fail: archiveSuperseded() losing its
// `->where('net_cents', $netCents)` scoping (its own docblock is about
// precisely why that scoping has to hold) would sweep the 2900-net row up
// alongside the 3900-net one it is meant to supersede, and remember()
// rewritten as an updateOrCreate() keyed without `amount_cents` — the
// plausible-looking fix for the UniqueConstraintViolationException catch
// a few lines below it — would silently overwrite the very row these
// assertions read.
//
// Neither of adoption's two guards is isolated by this test — the amount
// match and the `claimed` callback cover for each other in this scenario,
// which is why sabotaging either alone during this fix round left the
// booking untouched. Each is isolated on its own elsewhere in this file:
// it('adopts nothing when the amount, currency or interval differ') and
// it('never hands out a price a row already claims').
expect($booking->refresh()->stripe_price_id)->toBe($sold)
->and($this->stripe->archived)->not->toContain($sold)
// The OLD figure's Price is still what a checkout for it would use —
// adoption of the orphan at the NEW figure must not have reached
// backwards and pulled the frozen figure's Price out of circulation.
->and(app(AddonPrices::class)->liveFor(
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
))->toBe($sold)
// The row itself, not only whether it is archived — `archived_at`
// reads null for a row that is still live AND for one that is gone
// or rewritten onto a different net_cents, so `exists()` has to stand
// beside it or the row's disappearance would pass silently.
->and(StripeAddonPrice::query()
->where('addon_key', 'priority_support')
->where('reverse_charge', false)
->where('net_cents', 2900)
->where('interval', 'month')
->exists())->toBeTrue()
->and(StripeAddonPrice::query()
->where('addon_key', 'priority_support')
->where('reverse_charge', false)
->where('net_cents', 2900)
->where('interval', 'month')
->value('archived_at'))->toBeNull()
// And asking for the OLD figure again — the same call a renewal on
// this very booking would make — must still be handed $sold, not the
// orphan sitting at the new figure's amount.
->and(app(AddonPrices::class)->ensure(
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
))->toBe($sold);
});
it('takes over an orphaned package price', function () {
// A catalogue mirrored once, so families have Products and rows have Prices.
app(Kernel::class)->call('stripe:sync-catalogue');
$row = PlanPrice::query()->firstOrFail();
$charged = PlanPrices::chargedCents($row, TaxTreatment::domestic());
// The register loses its row and Stripe keeps the Price: a run that died
// between the two, seen from the next run's point of view.
$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();
// Load-bearing, not incidental cleanup: Stripe forgets an idempotency key
// after twenty-four hours, and that expiry is the only condition under
// which the 2026-07-29 duplicate was ever minted. With the sync's own key
// still in the fake's ledger, createPrice() would just replay $orphan's
// id on its own — proving nothing about adoption. Clearing it is what
// makes createPrice() able to mint a genuine second Price, which is the
// only way this test can fail.
$this->stripe->keys = [];
$before = count($this->stripe->prices);
$id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic());
expect($id)->toBe($orphan)
->and(count($this->stripe->prices))->toBe($before)
->and(StripePlanPrice::query()
->where('plan_price_id', $row->id)
->where('reverse_charge', false)
->where('charged_cents', $charged)
->value('stripe_price_id'))->toBe($orphan)
// The pointer for the ordinary domestic sale is written as before.
->and((string) $row->refresh()->stripe_price_id)->toBe($orphan);
});
it('does not take a package price belonging to another catalogue row', function () {
app(Kernel::class)->call('stripe:sync-catalogue');
$row = PlanPrice::query()->firstOrFail();
$product = (string) $row->version->family->stripe_product_id;
$charged = PlanPrices::chargedCents($row, TaxTreatment::domestic());
$interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month';
// This row's OWN domestic Price from the sync above becomes a legitimate
// orphan the moment its register row is gone — exactly like the test
// above — and adopt() would be right to reclaim it. Archived here so the
// only Price left at this exact figure is the wrong-owner one below:
// otherwise adoption would correctly adopt the row's own orphan before
// ever weighing 'price_other_row', and the rejection this test exists to
// prove would never be reached.
$this->stripe->archivePrice((string) StripePlanPrice::query()
->where('plan_price_id', $row->id)
->where('reverse_charge', false)
->value('stripe_price_id'));
StripePlanPrice::query()->where('plan_price_id', $row->id)->delete();
// Same product, same money, same interval — and plan_price_id says it is a
// DIFFERENT row's Price. One Product carries every version and term of a
// family, so this is the ordinary case, not an exotic one.
$this->stripe->plantPrice('price_other_row', $product, $charged, (string) $row->currency,
$interval, ['plan_price_id' => (string) ($row->id + 1000), 'tax_treatment' => 'domestic']);
// Same reason as the test above: with the sync's own idempotency key
// still in force, createPrice() would replay THIS row's own previous
// Price — which would also not be 'price_other_row', whether or not
// adoption ever looked at the wrong-owner Price at all. Clearing the
// ledger forces a real mint when nothing adoptable is found, which is
// what gives the assertions below something to catch.
$this->stripe->keys = [];
$before = count($this->stripe->prices);
$id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic());
expect($id)->not->toBe('price_other_row')
// A fresh Price was minted rather than 'price_other_row' being handed
// out. Proves AdoptStripePrice's contradicts()/confirms() pair
// correctly refused the wrong-owner Price WHEN adoption runs — the
// two guard each other here exactly as they do on the module side, so
// either alone still catches this; only disabling both together lets
// 'price_other_row' through. It does NOT prove PlanPrices wires
// adopt() into ensure() at all: skipping that wiring entirely mints
// fresh here too, indistinguishable from a correct refusal. The test
// above ("takes over an orphaned package price") is what proves the
// wiring is present.
->and(count($this->stripe->prices))->toBe($before + 1)
// The Price belonging to the other row must survive untouched.
// AdoptStripePrice archives every orphan but the one it adopts, so a
// defect that let it treat 'price_other_row' as a duplicate of
// whatever it did adopt would take this down as collateral damage —
// exactly the failure a wrong plan_price_id must never cause.
->and($this->stripe->archived)->not->toContain('price_other_row');
});