diff --git a/config/shop.php b/config/shop.php index 0d22487..52d649a 100644 --- a/config/shop.php +++ b/config/shop.php @@ -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 diff --git a/src/Contracts/EntitlementChecker.php b/src/Contracts/EntitlementChecker.php new file mode 100644 index 0000000..f24895b --- /dev/null +++ b/src/Contracts/EntitlementChecker.php @@ -0,0 +1,38 @@ + $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; +} diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php index f46fbb6..fdbcaa6 100644 --- a/src/Models/ProductPrice.php +++ b/src/Models/ProductPrice.php @@ -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|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 $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 $query + * @return Builder + */ + public function scopeConditional(Builder $query): Builder + { + return $query->where('meta', 'like', '%"requires"%'); + } + + /** + * Only standalone (unconditional) prices. + * + * @param Builder $query + * @return Builder + */ + 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 diff --git a/src/Services/DenyAllEntitlementChecker.php b/src/Services/DenyAllEntitlementChecker.php new file mode 100644 index 0000000..f284e39 --- /dev/null +++ b/src/Services/DenyAllEntitlementChecker.php @@ -0,0 +1,23 @@ +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() diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php index 5704a76..55f998a 100644 --- a/src/Traits/HasPrices.php +++ b/src/Traits/HasPrices.php @@ -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(); diff --git a/tests/Unit/Product/ConditionalPricingTest.php b/tests/Unit/Product/ConditionalPricingTest.php new file mode 100644 index 0000000..547bf9d --- /dev/null +++ b/tests/Unit/Product/ConditionalPricingTest.php @@ -0,0 +1,154 @@ + '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); + } +}