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,
|
'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
|
* Subscriptions are Cashier-backed. The package binds its own
|
||||||
* Cashier-extending Subscription / SubscriptionItem models (above) so it
|
* 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;
|
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
|
* 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
|
* 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) {
|
$this->app->singleton('shop.cart', function ($app) {
|
||||||
return new \Blax\Shop\Services\CartService();
|
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()
|
public function boot()
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,43 @@ trait HasPrices
|
||||||
return $this->prices()->where('is_default', true);
|
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
|
public function getPriceAttribute(): ?float
|
||||||
{
|
{
|
||||||
return $this->getCurrentPrice();
|
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