Commit 55b03a06 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(pos): charge a member the member price

The terminal read products.selling_price and never called the pricing engine,
so a product priced per membership type — a member rate and a walk-in rate,
which is how base_prices has always modelled it — sold at whichever single
number the catalogue carried. Members were charged the non-member price and
nothing on the receipt showed it had happened.

Every product line now comes from PricingService, which resolves the base
price for THIS buyer and then applies the academy's rules to it. selling_price
stays the fallback for a product nobody has priced through the engine: it is
the price the catalogue advertises, and refusing the sale outright would close
the shop over a configuration gap.

Cashiers scan first and identify the customer afterwards at least as often as
the other way round, so selecting or clearing a participant re-prices what is
already in the cart, and the product grid shows that buyer's price with the
list price struck through beside it — a rate the cashier cannot quote out loud
is a rate that gets argued about at the counter.

Checkout prices everything again before it believes any of it. The cart is a
public Livewire property, so the unit prices arriving at checkout are whatever
the browser last sent; if the engine's answer differs from what the cashier is
looking at, the sale stops rather than charging a total nobody saw.

Verified against a restored copy of the live tenant: with member/non-member
base prices on a real product, member 900, non-member 1,200, walk-in 1,200,
and a product with no base price falls back to its catalogue 8,000.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent f0f54ec3
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use App\Domain\POS\Enums\POSItemType; use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod; use App\Domain\POS\Enums\POSPaymentMethod;
use App\Domain\POS\Services\POSService; use App\Domain\POS\Services\POSService;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
...@@ -93,6 +94,8 @@ public function selectParticipant(int $id): void ...@@ -93,6 +94,8 @@ public function selectParticipant(int $id): void
$this->participantIsFree = (bool) $participant->is_free; $this->participantIsFree = (bool) $participant->is_free;
$this->participantSearch = ''; $this->participantSearch = '';
$this->searchResults = []; $this->searchResults = [];
$this->repriceCart();
} }
public function clearParticipant(): void public function clearParticipant(): void
...@@ -102,6 +105,8 @@ public function clearParticipant(): void ...@@ -102,6 +105,8 @@ public function clearParticipant(): void
$this->participantIsFree = false; $this->participantIsFree = false;
$this->searchResults = []; $this->searchResults = [];
$this->essentialWarnings = []; $this->essentialWarnings = [];
$this->repriceCart();
} }
public function addProduct(int $productId): void public function addProduct(int $productId): void
...@@ -120,7 +125,7 @@ public function addProduct(int $productId): void ...@@ -120,7 +125,7 @@ public function addProduct(int $productId): void
return; return;
} }
$unitPrice = $product->selling_price; ['base' => $basePrice, 'unit' => $unitPrice, 'snapshot' => $snapshot] = $this->priceFor($product);
$this->cart[] = [ $this->cart[] = [
'item_type' => POSItemType::Product->value, 'item_type' => POSItemType::Product->value,
...@@ -128,14 +133,100 @@ public function addProduct(int $productId): void ...@@ -128,14 +133,100 @@ public function addProduct(int $productId): void
'item_name_ar' => $product->name_ar, 'item_name_ar' => $product->name_ar,
'quantity' => 1, 'quantity' => 1,
'unit_price' => $unitPrice, 'unit_price' => $unitPrice,
'base_price' => $unitPrice, 'base_price' => $basePrice,
'discount_amount' => 0, 'discount_amount' => $basePrice - $unitPrice,
'line_total' => $unitPrice, 'line_total' => $unitPrice,
'pricing_snapshot' => [], 'pricing_snapshot' => $snapshot,
'metadata' => ['product_id' => $product->id, 'sku' => $product->sku], 'metadata' => ['product_id' => $product->id, 'sku' => $product->sku],
]; ];
} }
/**
* What this product costs THIS buyer, right now.
*
* A product can be priced per membership type — a member pays one rate, a
* walk-in another — and that only comes out of the pricing engine, which
* resolves the base price for the buyer and then applies the academy's
* rules to it. Reading products.selling_price instead, which is what this
* terminal used to do, charges every member the non-member price and there
* is nothing on the receipt to show it happened.
*
* selling_price stays the fallback for a product nobody has priced through
* the engine: it is the price the catalogue advertises, and refusing the
* sale outright would close the shop over a configuration gap.
*
* @return array{base: int, unit: int, snapshot: array}
*/
private function priceFor(Product $product, ?Participant $participant = null): array
{
$participant ??= $this->participantId ? Participant::find($this->participantId) : null;
try {
$result = app(PricingService::class)->calculate(
priceable: $product,
participant: $participant,
branchId: $this->getActiveBranchId() ?? $participant?->branch_id,
);
return [
'base' => $result->baseAmount,
'unit' => $result->finalAmount,
'snapshot' => $result->toArray(),
];
} catch (DomainException) {
// No base price for this product — the catalogue price stands.
return [
'base' => (int) $product->selling_price,
'unit' => (int) $product->selling_price,
'snapshot' => ['source' => 'catalogue_selling_price'],
];
}
}
/**
* Re-price every product already in the cart for the buyer now attached to it.
*
* Cashiers scan first and identify the customer afterwards at least as often
* as the other way round, so a member rate that is only applied at the moment
* a product is added is a member rate half the members never get.
*/
private function repriceCart(bool $announce = true): bool
{
$participant = $this->participantId ? Participant::find($this->participantId) : null;
$changed = false;
foreach ($this->cart as $index => $item) {
if (($item['item_type'] ?? '') !== POSItemType::Product->value || empty($item['item_id'])) {
continue;
}
$product = Product::find($item['item_id']);
if (! $product) {
continue;
}
['base' => $base, 'unit' => $unit, 'snapshot' => $snapshot] = $this->priceFor($product, $participant);
if ($unit !== (int) $item['unit_price']) {
$changed = true;
}
$quantity = max(1, (int) ($item['quantity'] ?? 1));
$this->cart[$index]['base_price'] = $base;
$this->cart[$index]['unit_price'] = $unit;
$this->cart[$index]['line_total'] = $unit * $quantity;
$this->cart[$index]['discount_amount'] = ($base - $unit) * $quantity;
$this->cart[$index]['pricing_snapshot'] = $snapshot;
}
if ($changed && $announce) {
session()->flash('info', __('تم تحديث أسعار السلة حسب فئة المشترك'));
}
return $changed;
}
private function checkEssentialProductHistory(Product $product): void private function checkEssentialProductHistory(Product $product): void
{ {
$yearStart = now()->startOfYear(); $yearStart = now()->startOfYear();
...@@ -329,6 +420,17 @@ public function checkout(POSService $posService): void ...@@ -329,6 +420,17 @@ public function checkout(POSService $posService): void
return; return;
} }
// The cart is a public property, so the prices arriving here are
// whatever the browser last sent. Price every product again from the
// engine before any of it is believed, and stop if the answer differs
// from what the cashier is looking at — the total charged has to be the
// total on screen.
if ($this->repriceCart(announce: false)) {
$this->showCheckout = false;
session()->flash('error', __('تغيّر سعر أحد المنتجات — راجع السلة قبل إتمام البيع'));
return;
}
// Validate deposit amount if using deposit // Validate deposit amount if using deposit
if ($this->useDeposit && $this->cartAllowsDeposit()) { if ($this->useDeposit && $this->cartAllowsDeposit()) {
$depositPiasters = (int) round($this->depositAmount * 100); $depositPiasters = (int) round($this->depositAmount * 100);
...@@ -435,8 +537,18 @@ public function render() ...@@ -435,8 +537,18 @@ public function render()
->orderBy('name_ar') ->orderBy('name_ar')
->get(); ->get();
// Show the grid the price this buyer will actually be charged. A member
// rate that only appears once the item is in the cart is a rate the
// cashier cannot quote out loud.
$participant = $this->participantId ? Participant::find($this->participantId) : null;
$productPrices = [];
foreach ($products as $product) {
$productPrices[$product->id] = $this->priceFor($product, $participant);
}
return view('livewire.pos.pos-terminal', [ return view('livewire.pos.pos-terminal', [
'products' => $products, 'products' => $products,
'productPrices' => $productPrices,
'cartTotal' => $this->getCartTotal(), 'cartTotal' => $this->getCartTotal(),
'cartDiscount' => $this->getCartDiscount(), 'cartDiscount' => $this->getCartDiscount(),
'cartSubtotal' => $this->getCartSubtotal(), 'cartSubtotal' => $this->getCartSubtotal(),
......
...@@ -120,7 +120,16 @@ class="flex flex-col items-start p-2.5 sm:p-3 min-h-[44px] border border-gray-20 ...@@ -120,7 +120,16 @@ class="flex flex-col items-start p-2.5 sm:p-3 min-h-[44px] border border-gray-20
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $product->name_ar }}</p> <p class="text-xs sm:text-sm font-medium text-gray-800 truncate">{{ $product->name_ar }}</p>
<p class="text-xs text-blue-600 font-bold" dir="ltr">{{ number_format($product->selling_price / 100, 2) }} {{ __('ج.م') }}</p> @php
$priced = $productPrices[$product->id] ?? ['base' => $product->selling_price, 'unit' => $product->selling_price];
@endphp
<p class="text-xs text-blue-600 font-bold" dir="ltr">
{{ number_format($priced['unit'] / 100, 2) }} {{ __('ج.م') }}
@if($priced['base'] > $priced['unit'])
{{-- The list price, so the cashier can see the rate applied rather than guess at it. --}}
<span class="font-normal text-gray-400 line-through">{{ number_format($priced['base'] / 100, 2) }}</span>
@endif
</p>
</div> </div>
</div> </div>
</button> </button>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment