Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
PriceService
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
0.00% covered (danger)
0.00%
0 / 1
 effectivePrice
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
1<?php
2
3namespace App\Services\Pricing;
4
5use App\Models\admin\Customer;
6use App\Models\PriceList;
7
8/**
9 * Resolves the price a specific customer pays for a product, per Settings →
10 * Features → Wholesale / customer-group pricing. Entirely opt-in:
11 *  - flag off, no customer, or no group assigned → the normal price, always.
12 *  - a matching price_lists row never makes the price go UP — only down or
13 *    unchanged, so a misconfigured rule can't accidentally overcharge anyone.
14 *
15 * NOTE: product/category detail pages are cached per-product (see
16 * App\Service\CacheService), not per-viewer, so the price shown there stays
17 * the retail price for every visitor regardless of their group — this
18 * service is applied where the price is captured for real (cart/checkout),
19 * which is what actually gets billed. Live per-viewer price display on the
20 * product page itself is deferred (would need to stop caching those views
21 * per-product and cache per-product-per-group instead).
22 */
23class PriceService
24{
25    /**
26     * @param  int  $productId  the product being priced
27     * @param  int|null  $categoryId  its category, if any (for category-wide rules)
28     * @param  float  $basePrice  the normal price (selling_price ?: price, already resolved by the caller)
29     * @param  int  $qty  quantity being purchased, for min-qty tiers
30     */
31    public function effectivePrice(int $productId, ?int $categoryId, float $basePrice, ?Customer $customer, int $qty = 1): float
32    {
33        if (! feature('wholesale_pricing') || ! $customer || ! $customer->customer_group_id) {
34            return $basePrice;
35        }
36
37        $rules = PriceList::query()
38            ->where('customer_group_id', $customer->customer_group_id)
39            ->where('min_qty', '<=', max(1, $qty))
40            ->where(function ($q) use ($productId, $categoryId) {
41                $q->where('product_id', $productId);
42                if ($categoryId) {
43                    $q->orWhere(function ($q2) use ($categoryId) {
44                        $q2->whereNull('product_id')->where('category_id', $categoryId);
45                    });
46                }
47            })
48            ->get();
49
50        if ($rules->isEmpty()) {
51            return $basePrice;
52        }
53
54        // Prefer a product-specific rule over a category-wide one; among
55        // equally-specific rules, prefer the highest qty tier the order
56        // actually qualifies for (the "buy more, save more" tier).
57        $best = $rules->sortByDesc(fn (PriceList $r) => ($r->product_id !== null ? 1_000_000 : 0) + $r->min_qty)->first();
58
59        $price = $best->price !== null
60            ? (float) $best->price
61            : round($basePrice * (1 - ((float) $best->percent_off / 100)), 2);
62
63        return min($price, $basePrice);
64    }
65}