feat(pricing): percentage conditional prices + cheapest-effective resolver + authoring helper

ProductPrice gains meta.percent_off support: percentOff()/isPercentage() and
effectiveAmount($base) — a percentage price resolves against a base (the
product's cheapest standalone price), so it can express e.g. '90% off with
Full Seat' rather than a fixed amount. Product::resolvePriceFor now ranks the
eligible prices by EFFECTIVE amount (percentage-aware) and returns the cheapest,
so a product linked to multiple prerequisites with different fixed/percentage
discounts always resolves to the cheapest the buyer qualifies for. Adds
ProductPrice::setConditionalPricing($requires, $percentOff, $label) — a
merge-safe authoring seam host admin UIs can call instead of hand-poking meta.
This commit is contained in:
Fabian @ Blax Software 2026-08-26 15:52:24 +02:00
parent 07a794c028
commit 2c31a23255
3 changed files with 194 additions and 1 deletions

View File

@ -194,6 +194,97 @@ class ProductPrice extends Model implements Cartable
return false;
}
/**
* The percentage discount this price applies (0 < pct <= 100) from
* `meta.percent_off`, or null when it is a fixed-amount price.
*
* A percentage price has no meaningful `unit_amount` of its own — its
* effective charge is computed against a base (the buyer's fallback
* standalone price) by {@see self::effectiveAmount()}.
*/
public function percentOff(): ?float
{
$pct = $this->meta->percent_off ?? null;
if ($pct === null || ! is_numeric($pct)) {
return null;
}
$pct = (float) $pct;
return ($pct > 0 && $pct <= 100) ? $pct : null;
}
/**
* Whether this price is expressed as a percentage discount rather than a
* fixed amount.
*/
public function isPercentage(): bool
{
return $this->percentOff() !== null;
}
/**
* The amount (in cents) this price effectively charges.
*
* Fixed price → its own `unit_amount`. Percentage price → `$base` reduced
* by the percent, where `$base` is the buyer's fallback (cheapest
* standalone) price for the same product, supplied by the caller
* ({@see \Blax\Shop\Traits\HasPrices::resolvePriceFor()}). Returns null when
* a percentage price has no base to discount — there is nothing to offer.
*/
public function effectiveAmount(?float $base = null): ?float
{
$pct = $this->percentOff();
if ($pct !== null) {
return $base === null ? null : round($base * (100 - $pct) / 100);
}
return $this->unit_amount;
}
/**
* Author this price's conditional-pricing meta in one merge-safe call —
* the reusable seam host admin UIs should use instead of hand-poking meta.
*
* @param array<string,mixed>|null $requires Prerequisite the buyer must
* hold, e.g. `['product' => 'full-seat']` or `['role' => 'seat']`;
* null/empty clears it (makes the price unconditional).
* @param float|null $percentOff Percentage discount (0 < pct <= 100)
* applied to the product's standalone price; null makes this a
* fixed-amount price (the row's `unit_amount` is charged as-is).
* @param string|null $label Display label ("8€ with Full Seat"); stored
* inside the requires map so {@see self::requiresLabel()} finds it.
*
* Does not persist — call `->save()` after.
*/
public function setConditionalPricing(?array $requires, ?float $percentOff = null, ?string $label = null): static
{
// Normalize meta (cast is `object`) to a plain array for editing.
$meta = json_decode(json_encode($this->meta ?? []), true);
$meta = is_array($meta) ? $meta : [];
if ($requires === null || $requires === []) {
unset($meta['requires']);
} else {
if ($label !== null && $label !== '') {
$requires['label'] = $label;
}
$meta['requires'] = $requires;
}
if ($percentOff === null) {
unset($meta['percent_off']);
} else {
$meta['percent_off'] = (float) $percentOff;
}
$this->meta = $meta;
return $this;
}
/**
* Only conditional prices (carry a `meta.requires`). Uses a JSON-string
* LIKE so it works whether `meta` is a JSON or TEXT column.

View File

@ -89,6 +89,12 @@ trait HasPrices
$checker = app(\Blax\Shop\Contracts\EntitlementChecker::class);
// Base for percentage prices = the cheapest standalone (unconditional)
// price for this product. Null when the product has none.
$base = $active
->reject(fn (ProductPrice $price) => $price->isConditional())
->min(fn (ProductPrice $price) => $price->unit_amount);
$eligible = $active->filter(function (ProductPrice $price) use ($buyer, $checker) {
if (! $price->isConditional()) {
return true;
@ -97,7 +103,13 @@ trait HasPrices
return $checker->satisfies($buyer, $price->requires());
});
return $eligible->sortBy(fn (ProductPrice $price) => $price->unit_amount)->first();
// Cheapest by EFFECTIVE amount (percentage prices resolve against $base).
// A percentage price with no base yields null and is dropped.
return $eligible
->map(fn (ProductPrice $price) => ['price' => $price, 'amount' => $price->effectiveAmount($base)])
->filter(fn (array $row) => $row['amount'] !== null)
->sortBy('amount')
->first()['price'] ?? null;
}
public function getPriceAttribute(): ?float

View File

@ -151,4 +151,94 @@ class ConditionalPricingTest extends TestCase
$this->assertSame(4500.0, $product->resolvePriceFor('seat-holder')->unit_amount);
}
#[Test]
public function percent_off_accessors(): void
{
$product = $this->productWithPrices([
['unit_amount' => 0, 'meta' => ['requires' => ['role' => 'seat'], 'percent_off' => 82]],
]);
$price = $product->prices()->first();
$this->assertSame(82.0, $price->percentOff());
$this->assertTrue($price->isPercentage());
$this->assertSame(900.0, $price->effectiveAmount(5000)); // 82% off 5000
$this->assertNull($price->effectiveAmount(null)); // no base -> not offerable
}
#[Test]
public function fixed_price_effective_amount_is_its_unit_amount(): void
{
$product = $this->productWithPrices([['unit_amount' => 800]]);
$this->assertFalse($product->prices()->first()->isPercentage());
$this->assertSame(800.0, $product->prices()->first()->effectiveAmount(5000));
}
#[Test]
public function resolve_price_uses_percentage_against_cheapest_standalone(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 5000],
['unit_amount' => 0, 'meta' => ['requires' => ['role' => 'seat'], 'percent_off' => 90]],
]);
$resolved = $product->resolvePriceFor('seat-holder');
$this->assertTrue($resolved->isPercentage());
$this->assertSame(500.0, $resolved->effectiveAmount(5000)); // 90% off 5000
// Without the entitlement -> the standalone 5000.
$this->assertSame(5000.0, $product->resolvePriceFor('rando')->unit_amount);
}
#[Test]
public function resolve_price_picks_cheapest_across_fixed_and_percentage(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 5000],
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]], // fixed 800
['unit_amount' => 0, 'meta' => ['requires' => ['role' => 'seat'], 'percent_off' => 90]], // 500
]);
// 500 (90% off) beats the fixed 800 and the 5000 standalone.
$this->assertTrue($product->resolvePriceFor('seat-holder')->isPercentage());
}
#[Test]
public function percentage_price_ignored_when_product_has_no_standalone(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 0, 'meta' => ['requires' => ['role' => 'seat'], 'percent_off' => 90]],
]);
// Nothing to discount against -> not offerable.
$this->assertNull($product->resolvePriceFor('seat-holder'));
}
#[Test]
public function set_conditional_pricing_authors_and_clears_meta(): void
{
$product = $this->productWithPrices([['unit_amount' => 0]]);
$price = $product->prices()->first();
$price->setConditionalPricing(['product' => 'full-seat'], 25.0, '25% with Full Seat')->save();
$price->refresh();
$this->assertSame(['product' => 'full-seat', 'label' => '25% with Full Seat'], $price->requires());
$this->assertSame(25.0, $price->percentOff());
$this->assertSame('25% with Full Seat', $price->requiresLabel());
// Passing null for both clears the conditional pricing entirely.
$price->setConditionalPricing(null, null)->save();
$price->refresh();
$this->assertNull($price->requires());
$this->assertFalse($price->isPercentage());
}
}