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:
parent
07a794c028
commit
2c31a23255
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue