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