feat(pricing): conditional prices via meta.requires + entitlement-checker resolver

Add a generic conditional-pricing mechanic: a ProductPrice may carry
meta.requires (e.g. {"role":"seat"}) so it only applies to buyers who
already hold a prerequisite (loyalty / bundle pricing). Product::resolvePriceFor
returns the cheapest price the buyer is eligible for, delegating the
'does the buyer satisfy this requirement' test to a host-bound
EntitlementChecker contract. Defaults to DenyAllEntitlementChecker so an
unconfigured host never leaks a discount. Adds requires()/isConditional()/
requiresLabel()/requiresSatisfiedBy() and conditional()/standalone() scopes.
This commit is contained in:
Fabian @ Blax Software 2026-08-26 12:35:14 +02:00
parent f10d5c2f04
commit 07a794c028
7 changed files with 382 additions and 0 deletions

View File

@ -63,6 +63,23 @@ return [
'license_seat' => \Blax\Shop\Models\LicenseSeat::class,
],
/*
* Conditional-price entitlement checker.
*
* A ProductPrice can carry `meta.requires` (e.g. {"role":"seat"}) so it is
* only offered to buyers who already hold something else — a subscription,
* role, or prior purchase (loyalty / bundle pricing). The package resolves
* the cheapest *eligible* price but delegates "does this buyer satisfy the
* requirement" to a host-provided \Blax\Shop\Contracts\EntitlementChecker,
* because entitlement semantics are app-specific (laravel-roles, Cashier,
* a custom table). Point this at your implementation's class-string.
*
* Left null, the package binds \Blax\Shop\Services\DenyAllEntitlementChecker
* — conditional prices are never offered until a real checker is wired up
* (fail-closed: a forgotten binding can't leak a discount to everyone).
*/
'entitlement_checker' => null,
/*
* Subscriptions are Cashier-backed. The package binds its own
* Cashier-extending Subscription / SubscriptionItem models (above) so it

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Contracts;
/**
* Decides whether a buyer satisfies a conditional-price requirement.
*
* Conditional pricing lets a {@see \Blax\Shop\Models\ProductPrice} apply only
* to buyers who already hold something else — a subscription, a role, a prior
* purchase. The package models the requirement (`ProductPrice.meta.requires`,
* e.g. `{"role":"seat"}`) and resolves the cheapest *eligible* price, but it
* has no opinion on what "the buyer holds the `seat` role" means — that is
* host-specific (laravel-roles, Cashier subscriptions, a custom entitlements
* table). The host binds an implementation of this contract; the package calls
* it from {@see \Blax\Shop\Traits\HasPrices::resolvePriceFor()}.
*
* Bind via `config('shop.entitlement_checker')` (a class-string resolved from
* the container). When unbound the package falls back to
* {@see \Blax\Shop\Services\DenyAllEntitlementChecker} — a conditional price is
* never offered until a host wires this up, so a forgotten binding can never
* leak a discount to everyone.
*/
interface EntitlementChecker
{
/**
* Does `$buyer` satisfy `$requirement`?
*
* @param mixed $buyer The buyer (usually a User model); may be null for
* a guest — guests satisfy nothing.
* @param array<string,mixed> $requirement The `requires` map from the
* price meta, e.g. `['role' => 'seat']` or
* `['product' => 'full-seat']`. The reserved key
* `label` is display-only and never a condition.
*/
public function satisfies(mixed $buyer, array $requirement): bool;
}

View File

@ -117,6 +117,108 @@ class ProductPrice extends Model implements Cartable
return $this->unit_amount;
}
/**
* The conditional-pricing requirement carried in `meta.requires`, or null
* when this is an unconditional (standalone) price.
*
* Shape: a map of one prerequisite, e.g. `['role' => 'seat']` or
* `['product' => 'full-seat']`, optionally with a display-only `label`
* ("8€ with Full Seat"). `meta` is cast to `object`, so this normalizes the
* `stdClass` back to an array for callers ({@see EntitlementChecker}).
*
* @return array<string,mixed>|null
*/
public function requires(): ?array
{
$requires = $this->meta->requires ?? null;
if ($requires === null) {
return null;
}
// Normalize stdClass|array -> array (meta cast is `object`).
$normalized = json_decode(json_encode($requires), true);
return is_array($normalized) && $normalized !== [] ? $normalized : null;
}
/**
* Whether this price only applies to buyers who satisfy a prerequisite
* (i.e. it carries `meta.requires`).
*/
public function isConditional(): bool
{
return $this->requires() !== null;
}
/**
* Display label for the requirement ("8€ with Full Seat"), if authored.
*/
public function requiresLabel(): ?string
{
$label = $this->requires()['label'] ?? null;
return is_string($label) && $label !== '' ? $label : null;
}
/**
* Structural satisfaction check against an already-resolved set of "owned"
* signal strings, e.g. `['role:seat', 'product:full-seat']`.
*
* Used for the hypothetical "if you owned product A" case (no runtime
* buyer) — the host resolves slugs/ids into signal strings and passes them
* here. A non-conditional price is always satisfied. A conditional price is
* satisfied when ANY of its (non-label) requirement entries matches a
* signal — a single `requires` map with one key is the common case.
*
* @param array<int,string> $ownedSignals
*/
public function requiresSatisfiedBy(array $ownedSignals): bool
{
$requires = $this->requires();
if ($requires === null) {
return true;
}
foreach ($requires as $type => $value) {
if ($type === 'label') {
continue;
}
if (is_scalar($value) && in_array($type . ':' . $value, $ownedSignals, true)) {
return true;
}
}
return false;
}
/**
* Only conditional prices (carry a `meta.requires`). Uses a JSON-string
* LIKE so it works whether `meta` is a JSON or TEXT column.
*
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeConditional(Builder $query): Builder
{
return $query->where('meta', 'like', '%"requires"%');
}
/**
* Only standalone (unconditional) prices.
*
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeStandalone(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNull('meta')->orWhere('meta', 'not like', '%"requires"%');
});
}
/**
* Tier ladder used when {@see $billing_scheme} is `tiered`. Each tier
* applies up to its `up_to` mark; the last tier (up_to = null) extends

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Services;
use Blax\Shop\Contracts\EntitlementChecker;
/**
* Safe default {@see EntitlementChecker}: nothing ever satisfies a requirement.
*
* Bound automatically when `config('shop.entitlement_checker')` is unset, so a
* conditional price stays hidden/refused until the host app provides a real
* checker. This makes "forgot to wire it up" fail closed (no discount) rather
* than open (discount for everyone).
*/
class DenyAllEntitlementChecker implements EntitlementChecker
{
public function satisfies(mixed $buyer, array $requirement): bool
{
return false;
}
}

View File

@ -26,6 +26,17 @@ class ShopServiceProvider extends ServiceProvider
$this->app->singleton('shop.cart', function ($app) {
return new \Blax\Shop\Services\CartService();
});
// Conditional-price entitlement checker. Resolve the host's binding
// from `shop.entitlement_checker`; fall back to a deny-all so an
// unconfigured host never offers a conditional price to everyone.
$this->app->bind(\Blax\Shop\Contracts\EntitlementChecker::class, function ($app) {
$configured = config('shop.entitlement_checker');
return $configured
? $app->make($configured)
: $app->make(\Blax\Shop\Services\DenyAllEntitlementChecker::class);
});
}
public function boot()

View File

@ -63,6 +63,43 @@ trait HasPrices
return $this->prices()->where('is_default', true);
}
/**
* Resolve the cheapest price this buyer may actually be charged, honouring
* conditional (`meta.requires`) pricing.
*
* The eligible set = every active standalone price PLUS every active
* conditional price the buyer satisfies (per the bound
* {@see \Blax\Shop\Contracts\EntitlementChecker}); the cheapest of those
* wins. So a buyer with the prerequisite gets the cheaper conditional price
* (e.g. 8€ with Full Seat), and a buyer without it never sees it.
*
* Returns null when nothing is eligible — e.g. a product sold ONLY at a
* conditional price to a buyer who doesn't qualify (there is genuinely no
* price for them). Callers must treat null as "not purchasable by you".
*
* @param mixed $buyer Usually a User; null for a guest (satisfies nothing).
*/
public function resolvePriceFor(mixed $buyer = null): ?ProductPrice
{
$active = $this->prices()->where('active', true)->get();
if ($active->isEmpty()) {
return null;
}
$checker = app(\Blax\Shop\Contracts\EntitlementChecker::class);
$eligible = $active->filter(function (ProductPrice $price) use ($buyer, $checker) {
if (! $price->isConditional()) {
return true;
}
return $checker->satisfies($buyer, $price->requires());
});
return $eligible->sortBy(fn (ProductPrice $price) => $price->unit_amount)->first();
}
public function getPriceAttribute(): ?float
{
return $this->getCurrentPrice();

View File

@ -0,0 +1,154 @@
<?php
namespace Blax\Shop\Tests\Unit\Product;
use Blax\Shop\Contracts\EntitlementChecker;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
/**
* Test stub: satisfies `['role' => 'seat']` only for a buyer marked
* 'seat-holder'. Stands in for a host app's laravel-roles-backed checker.
*/
class SeatCheckerStub implements EntitlementChecker
{
public function satisfies(mixed $buyer, array $requirement): bool
{
return $buyer === 'seat-holder' && ($requirement['role'] ?? null) === 'seat';
}
}
class ConditionalPricingTest extends TestCase
{
use RefreshDatabase;
private function productWithPrices(array $prices): Product
{
$product = Product::factory()->create();
foreach ($prices as $attrs) {
$product->prices()->create(array_merge([
'type' => 'one_time',
'billing_scheme' => 'per_unit',
'currency' => 'EUR',
'is_default' => false,
'active' => true,
], $attrs));
}
return $product->refresh();
}
#[Test]
public function requires_accessor_normalizes_meta_object(): void
{
$product = $this->productWithPrices([
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat', 'label' => '8€ with Full Seat']]],
]);
$price = $product->prices()->first();
$this->assertTrue($price->isConditional());
$this->assertSame(['role' => 'seat', 'label' => '8€ with Full Seat'], $price->requires());
$this->assertSame('8€ with Full Seat', $price->requiresLabel());
}
#[Test]
public function standalone_price_is_not_conditional(): void
{
$product = $this->productWithPrices([['unit_amount' => 4500]]);
$price = $product->prices()->first();
$this->assertFalse($price->isConditional());
$this->assertNull($price->requires());
$this->assertNull($price->requiresLabel());
}
#[Test]
public function requires_satisfied_by_matches_owned_signals(): void
{
$product = $this->productWithPrices([
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]],
['unit_amount' => 4500],
]);
[$conditional, $standalone] = [
$product->prices()->conditional()->first(),
$product->prices()->standalone()->first(),
];
$this->assertTrue($conditional->requiresSatisfiedBy(['role:seat']));
$this->assertTrue($conditional->requiresSatisfiedBy(['product:x', 'role:seat']));
$this->assertFalse($conditional->requiresSatisfiedBy(['role:other']));
$this->assertFalse($conditional->requiresSatisfiedBy([]));
// A non-conditional price is always satisfied.
$this->assertTrue($standalone->requiresSatisfiedBy([]));
}
#[Test]
public function resolve_price_picks_cheaper_conditional_when_buyer_qualifies(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 4500],
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]],
]);
$this->assertSame(800.0, $product->resolvePriceFor('seat-holder')->unit_amount);
}
#[Test]
public function resolve_price_falls_back_to_standalone_without_entitlement(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 4500],
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]],
]);
$this->assertSame(4500.0, $product->resolvePriceFor('rando')->unit_amount);
$this->assertSame(4500.0, $product->resolvePriceFor(null)->unit_amount);
}
#[Test]
public function default_deny_checker_never_offers_conditional_price(): void
{
// No entitlement_checker configured -> DenyAllEntitlementChecker.
$product = $this->productWithPrices([
['unit_amount' => 4500],
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]],
]);
$this->assertSame(4500.0, $product->resolvePriceFor('seat-holder')->unit_amount);
}
#[Test]
public function conditional_only_product_has_no_price_for_unqualified_buyer(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 800, 'meta' => ['requires' => ['role' => 'seat']]],
]);
$this->assertSame(800.0, $product->resolvePriceFor('seat-holder')->unit_amount);
$this->assertNull($product->resolvePriceFor('rando'));
}
#[Test]
public function inactive_conditional_price_is_ignored(): void
{
config()->set('shop.entitlement_checker', SeatCheckerStub::class);
$product = $this->productWithPrices([
['unit_amount' => 4500],
['unit_amount' => 800, 'active' => false, 'meta' => ['requires' => ['role' => 'seat']]],
]);
$this->assertSame(4500.0, $product->resolvePriceFor('seat-holder')->unit_amount);
}
}