684 lines
34 KiB
PHP
684 lines
34 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\IdempotencyKey;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
/**
|
|
* 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',
|
|
]);
|
|
|
|
// Stripe's own side of that morning, and load-bearing for the same reason as
|
|
// the cleared ledger in 'takes over an orphaned package price' below — only
|
|
// the other way round. Without an entry here createPrice() would simply mint,
|
|
// and the test could not tell "adoption prevented the 400" from "no 400 was
|
|
// possible". So the ledger is seeded with what the pre-9da1358 call left
|
|
// behind: the id it was answered with, and a fingerprint over the metadata
|
|
// that call sent — `addon` alone, before tax_treatment existed. The entry sits
|
|
// under the header today's code sends, because that is the only way a ledger
|
|
// keyed by header can put this createPrice() in front of a poisoned key: the
|
|
// fingerprint is folded into the header now, so a changed call also changes
|
|
// the header, which is the branch's OTHER half of the same fix. Seeded
|
|
// directly, therefore, and honestly so — what it stands in for is Stripe
|
|
// holding this call's key against different parameters.
|
|
$this->stripe->keys[IdempotencyKey::forPrice(
|
|
'clupilot-addon-price-priority_support-month-3480-EUR',
|
|
'prod_support', 3480, 'EUR', 'month', moduleMetadata(),
|
|
)] = [
|
|
'id' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw',
|
|
'fingerprint' => IdempotencyKey::fingerprint(IdempotencyKey::priceParameters(
|
|
'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support'],
|
|
)),
|
|
];
|
|
|
|
$id = app(AddonPrices::class)->ensure(
|
|
'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
|
|
);
|
|
|
|
// Adoption is what keeps createPrice() from ever being reached. Take the
|
|
// adopt() call out of AddonPrices::ensure() and this test does not merely
|
|
// return the wrong id — it dies inside the fake with Stripe's own sentence,
|
|
// 'Keys for idempotent requests can only be used with the same parameters
|
|
// they were first used with.' That is the 2026-07-29 blockade, reproduced.
|
|
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');
|
|
});
|
|
|
|
it('does not adopt a package price that only proves the family, not this row', function () {
|
|
// The whole reason the plan side needs `identifying: ['plan_price_id']`
|
|
// rather than the module side's simpler key: one Product carries a Price
|
|
// for every version, term AND treatment of a family, so a Price that
|
|
// proves only the FAMILY could be any of them. If `identifying` were ever
|
|
// loosened to ['plan_family'], a Price like the one planted below would
|
|
// wrongly confirm — nothing else here would catch it.
|
|
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 is a legitimate orphan the moment its
|
|
// register row is gone — same reason as the two tests above — and would
|
|
// otherwise be correctly re-adopted before the planted Price below is
|
|
// ever weighed, masking the very rejection this test means to prove.
|
|
$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();
|
|
|
|
// Right product, right money, right currency, right interval, and the
|
|
// right FAMILY — the one thing missing is plan_price_id, which is the
|
|
// only key that says which of the family's many rows a Price belongs to.
|
|
$this->stripe->plantPrice('price_family_only', $product, $charged, (string) $row->currency,
|
|
$interval, ['plan_family' => $row->version->family->key]);
|
|
|
|
// Same reason as the two tests above: with the sync's own idempotency
|
|
// key still in force, createPrice() would replay this row's own previous
|
|
// Price rather than genuinely minting, leaving nothing for the
|
|
// assertions below to catch.
|
|
$this->stripe->keys = [];
|
|
|
|
$before = count($this->stripe->prices);
|
|
|
|
$id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic());
|
|
|
|
expect($id)->not->toBe('price_family_only')
|
|
// A fresh Price was minted rather than the family-only Price being
|
|
// handed out — this is the assertion `identifying: ['plan_price_id']`
|
|
// exists to keep true; swap it for ['plan_family'] and this fails.
|
|
->and(count($this->stripe->prices))->toBe($before + 1)
|
|
->and($this->stripe->archived)->not->toContain('price_family_only');
|
|
});
|
|
|
|
it('lets no two module rows claim one stripe price', function () {
|
|
$shared = [
|
|
'addon_key' => 'priority_support', 'net_cents' => 2900, 'currency' => 'EUR',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared',
|
|
];
|
|
|
|
StripeAddonPrice::query()->create([...$shared,
|
|
'reverse_charge' => false, 'amount_cents' => 3480, 'interval' => 'month']);
|
|
|
|
// Two rows on one Price is what Block D warned about: archiving the one
|
|
// would withdraw the Price the other is still selling.
|
|
expect(fn () => StripeAddonPrice::query()->create([...$shared,
|
|
'reverse_charge' => true, 'amount_cents' => 2900, 'interval' => 'month']))
|
|
->toThrow(UniqueConstraintViolationException::class);
|
|
});
|
|
|
|
it('keeps only the lowest-id row in every group of duplicates, and leaves the rest of the table alone', function () {
|
|
// This is the one branch of 2026_07_31_210000_one_row_per_stripe_price that
|
|
// deletes rows from a live table, and it cannot be reached through the
|
|
// ordinary migrated schema: RefreshDatabase already ran this migration, so
|
|
// its OWN unique index would refuse the very duplicate this test needs to
|
|
// insert. Dropping the index first puts the table back into the shape the
|
|
// migration was written to find — the shape a real, never-yet-migrated
|
|
// production table was in — so up() can be run again and actually take its
|
|
// delete branch instead of finding nothing to do.
|
|
Schema::table('stripe_addon_prices', fn (Blueprint $table) => $table->dropUnique('stripe_addon_prices_price_unique'));
|
|
|
|
// Group one: two rows sharing a Price, differing in `reverse_charge` so the
|
|
// pre-existing COMPOSITE unique index (addon_key, reverse_charge,
|
|
// amount_cents, currency, interval) does not itself refuse the insert —
|
|
// the VAT-rate-zero case Block D warned about, where only the shared
|
|
// stripe_price_id gives the duplicate away.
|
|
$kept = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => false,
|
|
'amount_cents' => 2900, 'net_cents' => 2900, 'currency' => 'EUR', 'interval' => 'month',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
$duplicate = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => true,
|
|
'amount_cents' => 2900, 'net_cents' => 2900, 'currency' => 'EUR', 'interval' => 'month',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
// Group two: THREE rows sharing a second, different Price — its own tuple
|
|
// dimension varied per row (interval, then currency) so the composite index
|
|
// never fires here either. One group of two proves the loop runs; this one
|
|
// proves it runs more than once AND that a single iteration can delete more
|
|
// than one row.
|
|
$kept2 = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => false,
|
|
'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'EUR', 'interval' => 'month',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
$duplicate2a = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => false,
|
|
'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'EUR', 'interval' => 'year',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
$duplicate2b = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => false,
|
|
'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'USD', 'interval' => 'month',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
// On its own Price entirely — not part of any duplicate group — and must
|
|
// survive completely untouched.
|
|
$unrelated = DB::table('stripe_addon_prices')->insertGetId([
|
|
'addon_key' => 'extra_storage', 'reverse_charge' => false,
|
|
'amount_cents' => 1000, 'net_cents' => 1000, 'currency' => 'EUR', 'interval' => 'month',
|
|
'stripe_product_id' => 'prod_storage', 'stripe_price_id' => 'price_untouched',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
|
|
// require, not require_once: the file `return`s a fresh anonymous-class
|
|
// instance every time it is evaluated, which is what lets this run the
|
|
// migration's up() a second time in this same process.
|
|
(require database_path('migrations/2026_07_31_210000_one_row_per_stripe_price.php'))->up();
|
|
|
|
$keptRow = DB::table('stripe_addon_prices')->where('id', $kept)->first();
|
|
$kept2Row = DB::table('stripe_addon_prices')->where('id', $kept2)->first();
|
|
$unrelatedRow = DB::table('stripe_addon_prices')->where('id', $unrelated)->first();
|
|
|
|
// The LOWER id survives in each group — the row that was written first,
|
|
// which is the one anything already billing is likeliest to have been
|
|
// reading. Were that ordering silently inverted, the surviving row could be
|
|
// one nothing ever pointed at. Read back, not merely checked for existence:
|
|
// a survivor that exists but was mangled in the process — the wrong
|
|
// stripe_price_id, or an amount_cents that no longer matches what the
|
|
// shared Price actually charges — would be exactly the "kept the right row
|
|
// but damaged it" failure existence alone cannot catch.
|
|
expect($keptRow)->not->toBeNull()
|
|
->and($keptRow->stripe_price_id)->toBe('price_shared')
|
|
->and((int) $keptRow->amount_cents)->toBe(2900)
|
|
->and($kept2Row)->not->toBeNull()
|
|
->and($kept2Row->stripe_price_id)->toBe('price_shared_two')
|
|
->and((int) $kept2Row->amount_cents)->toBe(3900)
|
|
->and($unrelatedRow)->not->toBeNull()
|
|
->and($unrelatedRow->stripe_price_id)->toBe('price_untouched')
|
|
->and((int) $unrelatedRow->amount_cents)->toBe(1000)
|
|
// Every duplicate in both groups is gone — the higher id from group
|
|
// one, and BOTH higher ids from group two's three-way tie.
|
|
->and(DB::table('stripe_addon_prices')->where('id', $duplicate)->exists())->toBeFalse()
|
|
->and(DB::table('stripe_addon_prices')->where('id', $duplicate2a)->exists())->toBeFalse()
|
|
->and(DB::table('stripe_addon_prices')->where('id', $duplicate2b)->exists())->toBeFalse()
|
|
// Six rows went in, three survive: nothing beyond the two kept
|
|
// duplicates and the untouched row is left standing.
|
|
->and(DB::table('stripe_addon_prices')->count())->toBe(3);
|
|
|
|
// And the index is back in force: a further row on a third, otherwise
|
|
// distinct tuple, but the SAME Price id, is refused by stripe_price_id
|
|
// alone rather than being silently accepted.
|
|
expect(fn () => DB::table('stripe_addon_prices')->insert([
|
|
'addon_key' => 'priority_support', 'reverse_charge' => false,
|
|
'amount_cents' => 4900, 'net_cents' => 4900, 'currency' => 'EUR', 'interval' => 'year',
|
|
'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared',
|
|
'created_at' => now(), 'updated_at' => now(),
|
|
]))->toThrow(UniqueConstraintViolationException::class);
|
|
});
|
|
|
|
it('refuses a price whose recurrence is not one we could have minted', function () {
|
|
// The money gate, asked of AdoptStripePrice itself rather than of the
|
|
// client's filter. Until this moved, these four properties were rejected
|
|
// inside HttpStripeClient — and FakeStripeClient could not express them, so
|
|
// no test of this class could reach the promise its docblock makes.
|
|
$this->stripe->plantPrice('price_quarterly', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), intervalCount: 3);
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
});
|
|
|
|
it('refuses a price that meters usage instead of selling a licence', function () {
|
|
$this->stripe->plantPrice('price_metered', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), usageType: 'metered');
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
});
|
|
|
|
it('refuses a price that divides the quantity before charging', function () {
|
|
// Modules are billed BY quantity — SyncStripeAddonItems sums a pack into one
|
|
// item at quantity n — so a divide_by price would charge a customer holding
|
|
// three for one.
|
|
$this->stripe->plantPrice('price_divided', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), transformQuantity: true);
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
});
|
|
|
|
it('refuses a price that keeps its money in tiers', function () {
|
|
$this->stripe->plantPrice('price_tiered', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), billingScheme: 'tiered');
|
|
|
|
expect(adoptModulePrice())->toBeNull();
|
|
});
|
|
|
|
it('still adopts a price that carries every one of Stripe\'s defaults', function () {
|
|
// The direction that matters more than the four above: read the wrong way
|
|
// round, the gate refuses every legitimate price and recognition becomes a
|
|
// permanent no-op that nothing would ever alert on.
|
|
$this->stripe->plantPrice('price_ordinary', 'prod_support', 3480, 'EUR', 'month', moduleMetadata());
|
|
|
|
expect(adoptModulePrice())->toBe('price_ordinary');
|
|
});
|
|
|
|
it('warns about an unexplained price once a day, not once a booking', function () {
|
|
Log::spy();
|
|
|
|
$this->stripe->plantPrice('price_by_hand', 'prod_support', 3480, 'EUR', 'month', []);
|
|
|
|
// ensure() runs inside a customer's module booking, so without a throttle
|
|
// this line is written every time anybody books anything.
|
|
adoptModulePrice();
|
|
adoptModulePrice();
|
|
adoptModulePrice();
|
|
|
|
Log::shouldHaveReceived('warning')->once();
|
|
});
|
|
|
|
it('breaks a tie on created by the price id, so two runs agree', function () {
|
|
// plantPrice defaults created to 1 and createPrice stamps count+1, so a
|
|
// planted orphan can tie with a minted price. A tie decided by Stripe's list
|
|
// order would adopt one price today and the other tomorrow.
|
|
$this->stripe->plantPrice('price_bbb', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), created: 7);
|
|
$this->stripe->plantPrice('price_aaa', 'prod_support', 3480, 'EUR', 'month',
|
|
moduleMetadata(), created: 7);
|
|
|
|
expect(adoptModulePrice())->toBe('price_aaa')
|
|
->and($this->stripe->archived)->toBe(['price_bbb']);
|
|
});
|