From 2c31a2325567e1eddb2fa4d481e6ade068325879 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 26 Aug 2026 15:52:24 +0200 Subject: [PATCH] feat(pricing): percentage conditional prices + cheapest-effective resolver + authoring helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Models/ProductPrice.php | 91 +++++++++++++++++++ src/Traits/HasPrices.php | 14 ++- tests/Unit/Product/ConditionalPricingTest.php | 90 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php index fdbcaa6..1118314 100644 --- a/src/Models/ProductPrice.php +++ b/src/Models/ProductPrice.php @@ -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|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. diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php index 55f998a..1a74e0b 100644 --- a/src/Traits/HasPrices.php +++ b/src/Traits/HasPrices.php @@ -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 diff --git a/tests/Unit/Product/ConditionalPricingTest.php b/tests/Unit/Product/ConditionalPricingTest.php index 547bf9d..dee0a1f 100644 --- a/tests/Unit/Product/ConditionalPricingTest.php +++ b/tests/Unit/Product/ConditionalPricingTest.php @@ -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()); + } }