Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
CRAP | |
0.00% |
0 / 1 |
| PriceService | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
8 | |
0.00% |
0 / 1 |
| effectivePrice | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
8 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services\Pricing; |
| 4 | |
| 5 | use App\Models\admin\Customer; |
| 6 | use 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 | */ |
| 23 | class 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 | } |