Commit 22d43e2b authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(pos): charge the member rate, and sell on the plan that was configured

A product carries a member rate, a non-member rate, and instalment plans
open to one tier or the other. Only the registration wizard ever read any
of it. The till resolved prices through the pricing engine, the engine
knew only about base_prices, and nothing writes a product's member rate
there — on OC-Sport there is not one base_prices row for any product — so
every calculate() threw, the terminal fell back to selling_price, and
every member buying at reception paid the walk-in price with nothing on
the receipt to say so. The 8,000 card costs members 6,000; they were
charged 8,000. The plans were invisible too: the only partial payment the
terminal offered was a free-typed عربون, so a receptionist taking the
first instalment of an agreed schedule typed it into a manual line, and
the sale left no plan behind for anything to track.

The precedence now lives in the engine, once, so every caller gets it:
a base price tagged with this membership type wins, then the product's
own column for this tier, then any other base price. selling_price stays
out of it — it is the catalogue's advertised number, not a configured
price, and admitting it would make "nobody set a price" undetectable,
which is the hard fail the pricing rules require. The terminal falls back
to priceForTier() only when the engine has nothing at all, and says on
screen which price list is in force.

Plans reach the terminal as a per-line picker: pick a schedule, choose
how many instalments are being paid today, see the rest. Everything is
re-resolved in POSService from the database — the cart is a public
property, so a plan id in it is a number the browser chose, and a plan
belonging to another product or to the other tier is refused rather than
ignored. participantId is #[Locked] for the same reason: it now decides
which price list the sale is quoted from.

buildSchedule() rounded every auto slot up, so three instalments of an
8,000.00 card came to 8,000.01 — a plan asking for a piaster the invoice
never billed, which could never reach `completed`. Piasters split the way
they do everywhere else here: floor each share, last slot takes the
remainder.

Verified against the restored OC-Sport tenant: member #18 prices at
6,000.00 on the 2,000x3 members' plan, non-member #257 at 8,000.00 on the
2,500/2,500/3,000 plan; both schedules sum exactly. Full suite green on
SQLite and on Postgres, POS terminal renders.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent fdeedfd2
......@@ -78,13 +78,28 @@ protected function casts(): array
public function priceForTier(string $membershipType): int
{
if ($membershipType === 'member' && $this->member_price !== null) {
return $this->member_price;
}
if ($membershipType === 'non_member' && $this->non_member_price !== null) {
return $this->non_member_price;
}
return $this->selling_price;
return $this->explicitTierPrice($membershipType) ?? $this->selling_price;
}
/**
* The price this product was deliberately given for one membership tier —
* or null when nobody set one.
*
* priceForTier() answers "what do we charge", and falls back to the
* catalogue price so a sale is never blocked. That fallback is exactly
* wrong for the pricing engine, which has to be able to tell "a member pays
* 8,000 because someone typed 8,000 in the member column" apart from "a
* member pays 8,000 because nobody filled that column in". Only the first
* is a membership-specific price, and only the first may outrank a base
* price row.
*/
public function explicitTierPrice(string $membershipType): ?int
{
return match ($membershipType) {
'member' => $this->member_price,
'non_member' => $this->non_member_price,
default => null,
};
}
public function isAnnual(): bool
......
......@@ -107,14 +107,19 @@ public function regularInstallmentAmount(int $totalPiasters): int
/**
* Build the full schedule of amounts for all slots.
* Uses explicit amounts where set, auto-calculates the rest.
*
* The schedule sums to exactly $totalPiasters. It used to round each auto
* slot up, so three instalments of a 8,000.00 card came to 8,000.01 and the
* plan asked for a piaster the invoice never billed — a plan that can never
* reach `completed`. Piasters are split the way every other split in this
* system is: floor each share, and the last auto slot carries the
* remainder.
*/
public function buildSchedule(int $totalPiasters): array
{
$schedule = [];
$explicitSum = 0;
$explicitSlots = [];
$explicitSum = 0;
// First pass: collect explicit slots
for ($i = 0; $i < $this->installments; $i++) {
$explicit = $this->installment_amounts[$i] ?? null;
if ($explicit !== null && $explicit > 0) {
......@@ -123,14 +128,23 @@ public function buildSchedule(int $totalPiasters): array
}
}
$autoSlots = $this->installments - count($explicitSlots);
$autoIndexes = array_values(array_diff(range(0, $this->installments - 1), array_keys($explicitSlots)));
$autoCount = count($autoIndexes);
$remainingForAuto = max(0, $totalPiasters - $explicitSum);
$autoAmount = $autoSlots > 0 ? (int) ceil($remainingForAuto / $autoSlots) : 0;
for ($i = 0; $i < $this->installments; $i++) {
$schedule[$i] = $explicitSlots[$i] ?? $autoAmount;
$schedule = $explicitSlots;
if ($autoCount > 0) {
$share = intdiv($remainingForAuto, $autoCount);
foreach ($autoIndexes as $position => $index) {
$schedule[$index] = $position === $autoCount - 1
? $remainingForAuto - ($share * ($autoCount - 1))
: $share;
}
}
ksort($schedule);
return $schedule;
}
......
......@@ -3,6 +3,8 @@
namespace App\Domain\POS\Services;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Services\CashSessionService;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService;
......@@ -10,6 +12,7 @@
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\PlatformFeeService;
......@@ -43,7 +46,10 @@ public function __construct(
/**
* Process a full POS transaction (10-step flow, single DB::transaction).
*
* @param array $cartItems Each item: ['item_type', 'item_id', 'item_name_ar', 'quantity', 'unit_price', 'discount_amount', 'line_total', 'pricing_snapshot', 'metadata']
* @param array $cartItems Each item: ['item_type', 'item_id', 'item_name_ar', 'quantity', 'unit_price', 'discount_amount', 'line_total', 'pricing_snapshot', 'metadata'].
* A product line may also carry 'installment_plan_id' and 'pay_installments'; both are
* re-resolved from the database here, and a plan that does not belong to the line's
* product or to this buyer's membership tier is refused, not ignored.
* @param User $cashier The user processing the transaction
* @param int $branchId The branch where the sale occurs
* @param string $paymentMethod One of: cash, card, wallet, split
......@@ -110,15 +116,34 @@ public function processTransaction(
$serviceFeeAmount = $customerPays ? $this->platformFeeService->calculate($subtotal) : 0;
$totalAmount = $subtotal + $taxAmount + $serviceFeeAmount;
// An instalment plan says what is collected today, and it says it
// from the database — never from the amount the browser sent. Every
// plan named on a cart line is re-resolved against that line's
// product and this buyer's membership tier before a piaster of it
// is believed.
$plannedLines = $this->resolveInstallmentPlans($cartItems, $participant);
$planDueNow = $this->planDueNow($cartItems, $plannedLines, $serviceFeeAmount);
// Step 6-7: Validate payment
$paymentMethodEnum = POSPaymentMethod::from($paymentMethod);
if ($planDueNow !== null) {
// A plan and a deposit are two answers to the same question.
$depositAmount = null;
}
$isDeposit = $depositAmount !== null && $depositAmount > 0 && $depositAmount < $totalAmount;
$amountToPayNow = $isDeposit ? $depositAmount : $totalAmount;
$amountToPayNow = match (true) {
$planDueNow !== null => min($planDueNow, $totalAmount),
$isDeposit => $depositAmount,
default => $totalAmount,
};
$isPartial = $amountToPayNow < $totalAmount;
$this->validatePayment($paymentMethodEnum, $amountToPayNow, $participant, $splitPayments);
// Step 8: Commit — create POS transaction
$receiptNumber = $this->generateReceiptNumber($branchId);
$paymentStatus = $isDeposit ? POSPaymentStatus::PartiallyPaid : POSPaymentStatus::Completed;
$paymentStatus = $isPartial ? POSPaymentStatus::PartiallyPaid : POSPaymentStatus::Completed;
$posTransaction = POSTransaction::create([
'academy_id' => $cashSession->academy_id,
......@@ -134,10 +159,30 @@ public function processTransaction(
'payment_method' => $paymentMethodEnum->value,
'payment_status' => $paymentStatus->value,
'coupon_code' => $couponCode,
'notes' => $isDeposit ? ($notes ? $notes . ' | ' : '') . 'دفع مقدم (عربون)' : $notes,
'notes' => match (true) {
$planDueNow !== null => ($notes ? $notes . ' | ' : '') . 'بيع بالتقسيط',
$isDeposit => ($notes ? $notes . ' | ' : '') . 'دفع مقدم (عربون)',
default => $notes,
},
'processed_by' => $cashier->id,
'processed_at' => now(),
'metadata' => $isDeposit ? ['deposit_amount' => $depositAmount, 'remaining' => $totalAmount - $depositAmount] : [],
'metadata' => $isPartial
? [
'amount_due_now' => $amountToPayNow,
'remaining' => $totalAmount - $amountToPayNow,
'deposit_amount' => $isDeposit ? $depositAmount : null,
'installment_plans' => array_map(
fn ($line) => [
'product_id' => $line['product_id'],
'plan_id' => $line['plan']->id,
'label' => $line['plan']->label_ar,
'installments' => $line['plan']->installments,
'paying_now' => $line['pay'],
],
$plannedLines
),
]
: [],
]);
// Create POS transaction items
......@@ -184,6 +229,9 @@ public function processTransaction(
// Link invoice to POS transaction
$posTransaction->update(['invoice_id' => $invoice->id]);
// The schedule the customer agreed to, on the invoice that carries it.
$this->createPaymentPlans($invoice, $cartItems, $plannedLines, $cashSession->academy_id);
// Record payment(s) on the invoice (deposit pays only the deposit amount)
if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) {
foreach ($splitPayments as $sp) {
......@@ -234,6 +282,168 @@ public function processTransaction(
});
}
/**
* Every cart line that is genuinely being sold on an instalment plan.
*
* The cart reaches this service from a Livewire public property, so the
* plan id on a line is a number the browser chose. Three things have to be
* true before it buys anyone a schedule: the plan exists and belongs to the
* product on that line, the product is actually an annual one, and the plan
* is open to this buyer's membership tier — a members-only plan sold to a
* walk-in is the whole point of having tiers.
*
* @param array<int, array> $cartItems
* @return array<int, array{plan: ProductInstallmentPlan, pay: int, product_id: int}>
*/
private function resolveInstallmentPlans(array $cartItems, ?Participant $participant): array
{
$tier = $this->membershipTier($participant);
$resolved = [];
foreach ($cartItems as $index => $item) {
$planId = (int) ($item['installment_plan_id'] ?? 0);
$productId = (int) ($item['item_id'] ?? 0);
if ($planId <= 0 || $productId <= 0) {
continue;
}
$itemType = $item['item_type'] instanceof POSItemType
? $item['item_type']
: POSItemType::tryFrom((string) $item['item_type']);
if ($itemType !== POSItemType::Product) {
continue;
}
$product = Product::find($productId);
if (! $product || ! $product->isAnnual()) {
throw new DomainException('هذا المنتج لا يُباع بالتقسيط');
}
$plan = $product->installmentPlans()
->active()
->forTier($tier)
->whereKey($planId)
->first();
if (! $plan) {
throw new DomainException('خطة التقسيط غير متاحة لهذا المنتج أو لفئة هذا المشترك');
}
$resolved[$index] = [
'plan' => $plan,
'pay' => max(1, min((int) ($item['pay_installments'] ?? 1), $plan->installments)),
'product_id' => $productId,
];
}
return $resolved;
}
/**
* What a cart carrying instalment plans collects today, or null for a cart
* carrying none.
*
* A line on a plan owes the instalments being paid now; every other line
* owes its total in full. The service fee is charged once, on this visit,
* rather than spread across a schedule the platform is not party to.
*/
private function planDueNow(array $cartItems, array $plannedLines, int $serviceFeeAmount): ?int
{
if ($plannedLines === []) {
return null;
}
$due = $serviceFeeAmount;
foreach ($cartItems as $index => $item) {
$lineTotal = (int) $item['line_total'];
if (! isset($plannedLines[$index])) {
$due += $lineTotal;
continue;
}
$schedule = $plannedLines[$index]['plan']->buildSchedule($lineTotal);
$due += array_sum(array_slice($schedule, 0, $plannedLines[$index]['pay']));
}
return $due;
}
/**
* Write the agreed schedule against the invoice that carries it.
*
* One plan per product line, so a sale of a registration card and a kit on
* two different schedules stays two schedules. Instalments the customer is
* paying on this visit are marked paid immediately — the money is being
* taken in the same transaction — and next_due_date points at the first one
* that is not.
*/
private function createPaymentPlans($invoice, array $cartItems, array $plannedLines, int $academyId): void
{
foreach ($plannedLines as $index => $line) {
$plan = $line['plan'];
$schedule = $plan->buildSchedule((int) $cartItems[$index]['line_total']);
$regular = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$paymentPlan = PaymentPlan::create([
'academy_id' => $academyId,
'invoice_id' => $invoice->id,
'status' => $line['pay'] >= $plan->installments ? 'completed' : 'active',
'total_installments' => $plan->installments,
'paid_installments' => $line['pay'],
'installment_amount' => $regular,
'frequency' => $plan->frequency,
'start_date' => now()->toDateString(),
'next_due_date' => null,
'notes' => $plan->label_ar . ' — ' . ($cartItems[$index]['item_name_ar'] ?? ''),
]);
$dueDate = now();
$nextDue = null;
foreach ($schedule as $slot => $amount) {
$sequence = $slot + 1;
$paidNow = $sequence <= $line['pay'];
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $sequence,
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'status' => $paidNow ? 'paid' : 'pending',
'paid_at' => $paidNow ? now() : null,
]);
if (! $paidNow && $nextDue === null) {
$nextDue = $dueDate->toDateString();
}
$dueDate = match ($plan->frequency) {
'weekly' => $dueDate->copy()->addWeek(),
'biweekly' => $dueDate->copy()->addWeeks(2),
'quarterly' => $dueDate->copy()->addMonths(3),
default => $dueDate->copy()->addMonth(),
};
}
$paymentPlan->update(['next_due_date' => $nextDue]);
}
}
private function membershipTier(?Participant $participant): string
{
$type = $participant?->membership_type;
if ($type instanceof \BackedEnum) {
$type = $type->value;
}
return $type === 'member' ? 'member' : 'non_member';
}
/**
* Validate payment method-specific constraints.
*/
......
......@@ -10,7 +10,12 @@ public function __construct(
public readonly int $totalDiscount,
public readonly array $appliedRules,
public readonly ?array $appliedPromotion,
public readonly int $basePriceId,
/**
* The base_prices row the price came from — null when the base was the
* product's own member/non-member column, which is a price without a
* row of its own.
*/
public readonly ?int $basePriceId,
) {}
/**
......
......@@ -2,6 +2,7 @@
namespace App\Domain\Pricing\Services;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Enums\AdjustmentType;
use App\Domain\Pricing\Models\BasePrice;
......@@ -44,19 +45,24 @@ public function calculate(
$date ??= now()->toDateString();
$branchId ??= $this->resolveParticipantBranch($participant);
// Membership type: explicit override (new registration, no row yet) wins.
$membershipType = $contextOverride['membership_type']
?? $participant?->membership_type?->value
?? 'non_member';
// ─── Step 1: Find base price (fail if none exists) ────────────
$basePrice = $this->findBasePrice(
$priceable,
$branchId,
$date,
$participant,
$contextOverride['membership_type'] ?? null
);
if (!$basePrice) {
$basePrice = $this->findBasePrice($priceable, $branchId, $date, $participant, $membershipType);
$originalAmount = $this->resolveBaseAmount($priceable, $basePrice, $membershipType);
if ($originalAmount === null) {
throw new DomainException('لا يوجد سعر محدد');
}
$originalAmount = $basePrice->amount;
// The row only stands as the source when its amount is the one taken.
if ($basePrice && $basePrice->amount !== $originalAmount) {
$basePrice = null;
}
$currentAmount = $originalAmount;
$appliedRules = [];
$totalDiscount = 0;
......@@ -163,12 +169,55 @@ public function calculate(
totalDiscount: $totalDiscount,
appliedRules: $appliedRules,
appliedPromotion: $appliedPromotion,
basePriceId: $basePrice->id,
basePriceId: $basePrice?->id,
);
}
// ─── Step 1: Base Price Resolution ────────────────────────────────────
/**
* The amount the whole calculation starts from, once every place a price
* can be configured has had its say.
*
* Two surfaces set prices in this system and they disagreed silently. The
* pricing screen writes `base_prices` rows, optionally tagged with a
* membership type. The product screen writes `products.member_price` /
* `non_member_price` — and nothing ever read them outside the registration
* wizard, so every other till charged members the walk-in rate and no line
* on the receipt said so.
*
* They are ranked by how specifically they answer *this* buyer:
*
* 1. a base price tagged with this membership type — someone configured
* it for exactly this case, so nothing outranks it;
* 2. the product's own column for this tier — a deliberate member rate;
* 3. any other base price — the untagged row, or another branch's;
*
* and null when none of the three exists, which is the hard fail the rules
* require. `products.selling_price` is deliberately not in the list: it is
* the catalogue's advertised number, not a configured price, and letting it
* in here would make "no price set" impossible to detect.
*/
private function resolveBaseAmount(Model $priceable, ?BasePrice $basePrice, string $membershipType): ?int
{
$basePriceIsForThisTier = $basePrice
&& ($basePrice->metadata['membership_type'] ?? null) === $membershipType;
if ($basePriceIsForThisTier) {
return (int) $basePrice->amount;
}
$tierPrice = $priceable instanceof Product
? $priceable->explicitTierPrice($membershipType)
: null;
if ($tierPrice !== null) {
return (int) $tierPrice;
}
return $basePrice ? (int) $basePrice->amount : null;
}
private function findBasePrice(
Model $priceable,
?int $branchId,
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Services\CashSessionService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod;
......@@ -13,6 +14,7 @@
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -24,8 +26,21 @@ class POSTerminal extends Component
// Cart state
public array $cart = [];
/**
* The buyer, and everything read off their record.
*
* Locked: the only writers are selectParticipant()/clearParticipant(),
* which resolve the id through the branch-scoped model. Left plain, the
* browser could name any participant it liked — and since this id now
* decides which price list the till quotes and which tier's instalment
* plans are offered, it also decides what the sale costs.
*/
#[Locked]
public ?int $participantId = null;
#[Locked]
public string $participantName = '';
#[Locked]
public bool $participantIsFree = false;
public string $participantSearch = '';
public string $couponCode = '';
......@@ -63,6 +78,16 @@ class POSTerminal extends Component
// Essential product warnings (product was already purchased this year)
public array $essentialWarnings = [];
/**
* Per-request caches. Not public, and not meant to survive the request:
* anything the browser could set here would decide what a buyer is charged.
*
* @var array<int, Product|null>
*/
private array $productMemo = [];
private ?string $tierMemo = null;
public function mount(CashSessionService $cashSessionService): void
{
$this->authorize('pos.sell');
......@@ -88,6 +113,7 @@ public function updatedParticipantSearch(): void
public function selectParticipant(int $id): void
{
$this->tierMemo = null;
$participant = Participant::with('person')->findOrFail($id);
$this->participantId = $participant->id;
$this->participantName = $participant->person->name_ar;
......@@ -100,6 +126,7 @@ public function selectParticipant(int $id): void
public function clearParticipant(): void
{
$this->tierMemo = null;
$this->participantId = null;
$this->participantName = '';
$this->participantIsFree = false;
......@@ -137,10 +164,201 @@ public function addProduct(int $productId): void
'discount_amount' => $basePrice - $unitPrice,
'line_total' => $unitPrice,
'pricing_snapshot' => $snapshot,
'installment_plan_id' => null,
'pay_installments' => 1,
'metadata' => ['product_id' => $product->id, 'sku' => $product->sku],
];
}
/**
* Which price list this buyer is on.
*
* `membership_type` is cast to an enum on the model but arrives as a plain
* string from an import or a legacy row, and a walk-in with no participant
* at all is a non-member. All three answer to the same three values the
* products table and the instalment plans are keyed by.
*/
private function buyerTier(): string
{
if ($this->tierMemo !== null) {
return $this->tierMemo;
}
$participant = $this->participantId ? Participant::find($this->participantId) : null;
$type = $participant?->membership_type;
if ($type instanceof \BackedEnum) {
$type = $type->value;
}
return $this->tierMemo = $type === 'member' ? 'member' : 'non_member';
}
/**
* One lookup per product per request. cartSchedules() runs from three
* callers on every render, and without this each of them re-queried every
* line's product and its plans.
*/
private function product(int $id): ?Product
{
return $this->productMemo[$id] ??= Product::find($id);
}
/**
* The instalment plans this buyer may be offered for this product.
*
* Plans are configured per tier — "أعضاء فقط", "غير أعضاء فقط", or both —
* and a plan is only ever a plan for an annual product. Everything here is
* read from the database on every call rather than kept in the cart: the
* cart is a public property, so a plan id sitting in it is a number the
* browser sent, not a plan.
*/
private function plansFor(Product $product): \Illuminate\Support\Collection
{
if (! $product->isAnnual()) {
return collect();
}
$tier = $this->buyerTier();
if ($product->relationLoaded('installmentPlans')) {
return $product->installmentPlans
->where('is_active', true)
->filter(fn ($plan) => in_array($plan->membership_tier, [$tier, 'any'], true))
->sortBy('sort_order')
->values();
}
return $product->installmentPlans()
->active()
->forTier($tier)
->orderBy('sort_order')
->get();
}
/**
* Resolve the plan a cart line claims, or null if it may not have it.
*
* Guards the two ways a browser-supplied id goes wrong: a plan belonging to
* another product, and a plan for the other membership tier — which is
* exactly what happens on its own when a cashier picks the member plan and
* then attaches a non-member to the sale.
*/
private function resolvePlan(array $item): ?ProductInstallmentPlan
{
if (empty($item['installment_plan_id']) || empty($item['item_id'])) {
return null;
}
$product = $this->product((int) $item['item_id']);
if (! $product) {
return null;
}
return $this->plansFor($product)->firstWhere('id', (int) $item['installment_plan_id']);
}
public function setItemPlan(int $index, ?int $planId): void
{
if (! isset($this->cart[$index])) {
return;
}
if (! $planId) {
$this->cart[$index]['installment_plan_id'] = null;
$this->cart[$index]['pay_installments'] = 1;
return;
}
$plan = $this->resolvePlan(['installment_plan_id' => $planId, 'item_id' => $this->cart[$index]['item_id'] ?? null]);
if (! $plan) {
session()->flash('error', __('خطة التقسيط غير متاحة لهذا المنتج أو لفئة هذا المشترك'));
return;
}
$this->cart[$index]['installment_plan_id'] = $plan->id;
$this->cart[$index]['pay_installments'] = 1;
// A plan already says what is paid today; a deposit on top of it is a
// second answer to the same question.
$this->useDeposit = false;
$this->depositAmount = 0;
}
public function setPayInstallments(int $index, int $count): void
{
if (! isset($this->cart[$index])) {
return;
}
$plan = $this->resolvePlan($this->cart[$index]);
if (! $plan) {
return;
}
$this->cart[$index]['pay_installments'] = max(1, min($count, $plan->installments));
}
/**
* Every cart line's schedule, as the screen and the checkout both need it.
*
* @return array<int, array{plan: ProductInstallmentPlan, schedule: array<int,int>, pay: int, due_now: int}>
*/
private function cartSchedules(): array
{
$out = [];
foreach ($this->cart as $index => $item) {
$plan = $this->resolvePlan($item);
if (! $plan) {
continue;
}
$schedule = $plan->buildSchedule((int) $item['line_total']);
$pay = max(1, min((int) ($item['pay_installments'] ?? 1), $plan->installments));
$out[$index] = [
'plan' => $plan,
'schedule' => $schedule,
'pay' => $pay,
'due_now' => array_sum(array_slice($schedule, 0, $pay)),
];
}
return $out;
}
public function cartHasPlan(): bool
{
return $this->cartSchedules() !== [];
}
/**
* What the customer hands over today.
*
* A line on a plan owes the instalments being paid now; every other line
* owes its full total, and the platform's service fee is charged in full on
* the first visit rather than dribbled across the schedule.
*/
public function getAmountDueNow(): int
{
$schedules = $this->cartSchedules();
if ($schedules === []) {
return $this->getCartGrandTotal();
}
$due = $this->getServiceFee();
foreach ($this->cart as $index => $item) {
$due += $schedules[$index]['due_now'] ?? (int) $item['line_total'];
}
return min($due, $this->getCartGrandTotal());
}
/**
* What this product costs THIS buyer, right now.
*
......@@ -151,9 +369,10 @@ public function addProduct(int $productId): void
* 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.
* The engine now reads products.member_price / non_member_price as a
* membership-specific base price, so a rate typed on the product screen
* reaches this till too; priceForTier() is the fallback for a product that
* has no configured price at all.
*
* @return array{base: int, unit: int, snapshot: array}
*/
......@@ -174,11 +393,17 @@ private function priceFor(Product $product, ?Participant $participant = null): a
'snapshot' => $result->toArray(),
];
} catch (DomainException) {
// No base price for this product — the catalogue price stands.
// Nobody priced this product through the engine. priceForTier()
// still knows whether a member rate was typed on the product itself
// and falls back to the catalogue price only when it was not —
// refusing the sale outright would close the shop over a
// configuration gap.
$fallback = (int) $product->priceForTier($this->buyerTier());
return [
'base' => (int) $product->selling_price,
'unit' => (int) $product->selling_price,
'snapshot' => ['source' => 'catalogue_selling_price'],
'base' => $fallback,
'unit' => $fallback,
'snapshot' => ['source' => 'product_tier_price', 'membership_type' => $this->buyerTier()],
];
}
}
......@@ -227,6 +452,15 @@ private function repriceCart(bool $announce = true): bool
$this->cart[$index]['line_total'] = $unit * $quantity;
$this->cart[$index]['discount_amount'] = ($base - $unit) * $quantity;
$this->cart[$index]['pricing_snapshot'] = $snapshot;
// Plans are tier-scoped too. Attaching a non-member to a sale that
// was built on the members-only plan has to drop the plan, not
// quietly bill him on it.
if (! empty($item['installment_plan_id']) && ! $this->resolvePlan($this->cart[$index])) {
$this->cart[$index]['installment_plan_id'] = null;
$this->cart[$index]['pay_installments'] = 1;
$changed = true;
}
}
foreach ($unresolved as $index) {
......@@ -448,6 +682,14 @@ public function checkout(POSService $posService): void
return;
}
// A plan is its own answer to "how much today", so the deposit rules
// below do not apply and the two must not both be in play.
$hasPlan = $this->cartHasPlan();
if ($hasPlan) {
$this->useDeposit = false;
$this->depositAmount = 0;
}
// Validate deposit amount if using deposit
if ($this->useDeposit && $this->cartAllowsDeposit()) {
$depositPiasters = (int) round($this->depositAmount * 100);
......@@ -464,10 +706,10 @@ public function checkout(POSService $posService): void
}
}
// Validate split total (must cover grand total including service fee)
// Validate split total (must cover what is being collected today)
if ($this->paymentMethod === 'split' && !$this->useDeposit) {
$splitTotal = $this->getSplitTotal();
$grandTotal = $this->getCartGrandTotal();
$grandTotal = $this->getAmountDueNow();
if ($splitTotal < $grandTotal) {
session()->flash('error', __('المبلغ المدفوع أقل من الإجمالي'));
return;
......@@ -482,6 +724,10 @@ public function checkout(POSService $posService): void
$depositPiasters = (int) round($this->depositAmount * 100);
}
// The plan is rebuilt from the database inside POSService too — this
// is what the cashier is looking at, not what the sale is billed on.
$dueNow = $hasPlan ? $this->getAmountDueNow() : null;
$transaction = $posService->processTransaction(
cartItems: $this->cart,
cashier: auth()->user(),
......@@ -494,6 +740,10 @@ public function checkout(POSService $posService): void
depositAmount: $depositPiasters,
);
if ($dueNow !== null) {
$depositPiasters = $transaction->metadata['amount_due_now'] ?? $dueNow;
}
$this->lastReceiptNumber = $transaction->receipt_number;
$this->lastTransactionTotal = $transaction->total_amount;
$this->lastTransactionPaid = $depositPiasters ?? $transaction->total_amount;
......@@ -547,13 +797,23 @@ public function render()
{
$branchId = $this->getActiveBranchId() ?? auth()->user()->branch_id ?? null;
$tier = $this->buyerTier();
$products = Product::where('is_active', true)
->when($branchId, fn ($q) => $q->where(function ($sub) use ($branchId) {
$sub->where('branch_id', $branchId);
}))
// The grid needs to say which items can be put on a schedule, and
// only plans this buyer's tier is entitled to count.
->with(['installmentPlans' => fn ($q) => $q->active()->forTier($tier)->orderBy('sort_order')])
->orderBy('name_ar')
->get();
$productHasPlans = [];
foreach ($products as $product) {
$productHasPlans[$product->id] = $product->isAnnual() && $product->installmentPlans->isNotEmpty();
}
// 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.
......@@ -563,6 +823,47 @@ public function render()
$productPrices[$product->id] = $this->priceFor($product, $participant);
}
// Which lines can be put on a plan, and what each plan would cost —
// read fresh, so the options on screen are the options checkout will
// accept and nothing here comes back off the cart.
$cartPlans = [];
$cartSchedules = [];
foreach ($this->cart as $index => $item) {
if (($item['item_type'] ?? '') !== POSItemType::Product->value || empty($item['item_id'])) {
continue;
}
$product = $products->firstWhere('id', $item['item_id']) ?? Product::find($item['item_id']);
if (! $product) {
continue;
}
$plans = $this->plansFor($product);
if ($plans->isEmpty()) {
continue;
}
$cartPlans[$index] = $plans->map(fn ($plan) => [
'id' => $plan->id,
'label_ar' => $plan->label_ar,
'installments' => $plan->installments,
'frequency_label' => $plan->frequencyLabel(),
'tier_label' => $plan->tierLabel(),
'schedule' => array_values($plan->buildSchedule((int) $item['line_total'])),
])->values()->all();
}
foreach ($this->cartSchedules() as $index => $row) {
$cartSchedules[$index] = [
'label_ar' => $row['plan']->label_ar,
'installments' => $row['plan']->installments,
'frequency_label' => $row['plan']->frequencyLabel(),
'schedule' => array_values($row['schedule']),
'pay' => $row['pay'],
'due_now' => $row['due_now'],
];
}
return view('livewire.pos.pos-terminal', [
'products' => $products,
'productPrices' => $productPrices,
......@@ -572,6 +873,12 @@ public function render()
'serviceFee' => $this->getServiceFee(),
'cartGrandTotal' => $this->getCartGrandTotal(),
'paymentMethods' => POSPaymentMethod::cases(),
'productHasPlans' => $productHasPlans,
'cartPlans' => $cartPlans,
'cartSchedules' => $cartSchedules,
'amountDueNow' => $this->getAmountDueNow(),
'hasInstallmentPlan' => $cartSchedules !== [],
'buyerTier' => $this->buyerTier(),
'allowsDeposit' => $this->cartAllowsDeposit(),
'minimumDepositAmount' => $this->getMinimumDepositAmount(),
'minimumDepositPercent' => $this->getMinimumDepositPercent(),
......
......@@ -43,6 +43,11 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text
{{ $participantName }}
</span>
@if($participantIsFree) <x-ui.free-player-badge :small="true" /> @endif
{{-- Which price list the grid below is showing, so a member rate is
something the cashier can see rather than something they hope for. --}}
<span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium {{ $buyerTier === 'member' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-gray-100 text-gray-600 border border-gray-200' }}">
{{ $buyerTier === 'member' ? __('أسعار الأعضاء') : __('أسعار غير الأعضاء') }}
</span>
<button wire:click="clearParticipant" class="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
......@@ -50,6 +55,9 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text
</button>
</div>
@else
<span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium bg-gray-100 text-gray-600 border border-gray-200">
{{ __('أسعار غير الأعضاء') }}
</span>
<div class="relative flex-1">
<input type="text"
wire:model.live.debounce.300ms="participantSearch"
......@@ -109,7 +117,9 @@ class="flex-1 px-3 sm:px-4 py-3 min-h-[44px] text-xs sm:text-sm font-medium tran
@foreach($products as $product)
<button wire:click="addProduct({{ $product->id }})"
class="flex flex-col items-start p-2.5 sm:p-3 min-h-[44px] border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50/50 active:bg-blue-100 active:scale-[0.97] transition-all text-start group relative">
@if($product->allows_partial_payment)
@if(!empty($productHasPlans[$product->id]))
<span class="absolute top-1 end-1 px-1.5 py-0.5 text-[10px] font-bold bg-indigo-100 text-indigo-700 rounded">{{ __('تقسيط') }}</span>
@elseif($product->allows_partial_payment)
<span class="absolute top-1 end-1 px-1.5 py-0.5 text-[10px] font-bold bg-blue-100 text-blue-700 rounded">{{ __('عربون') }}</span>
@endif
<div class="flex items-center gap-2 w-full">
......@@ -295,6 +305,50 @@ class="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400
</svg>
</button>
</div>
{{-- Instalment plans, when this product has any open to this buyer's tier --}}
@if(!empty($cartPlans[$index]))
@php $activePlanId = $item['installment_plan_id'] ?? null; @endphp
<div class="-mt-1 mb-1 ms-2 me-2 p-2.5 rounded-b-lg bg-indigo-50/70 border border-t-0 border-indigo-100 space-y-2">
<p class="text-[11px] font-medium text-indigo-800">{{ __('التقسيط') }}</p>
<div class="flex flex-wrap gap-1.5">
<button type="button" wire:click="setItemPlan({{ $index }}, null)"
class="px-2 py-1 text-[11px] rounded-lg border transition-colors {{ $activePlanId ? 'bg-white border-gray-200 text-gray-600 hover:border-indigo-300' : 'bg-indigo-600 border-indigo-600 text-white' }}">
{{ __('دفعة واحدة') }}
</button>
@foreach($cartPlans[$index] as $plan)
<button type="button" wire:click="setItemPlan({{ $index }}, {{ $plan['id'] }})"
class="px-2 py-1 text-[11px] rounded-lg border transition-colors {{ (int) $activePlanId === $plan['id'] ? 'bg-indigo-600 border-indigo-600 text-white' : 'bg-white border-gray-200 text-gray-600 hover:border-indigo-300' }}">
{{ $plan['label_ar'] }}
<span class="opacity-70">· <span dir="ltr">{{ $plan['installments'] }}</span> {{ __('أقساط') }} {{ $plan['frequency_label'] }}</span>
</button>
@endforeach
</div>
@if(!empty($cartSchedules[$index]))
@php $sch = $cartSchedules[$index]; @endphp
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-1">
@foreach($sch['schedule'] as $slot => $amount)
<span class="px-1.5 py-0.5 rounded text-[10px] border {{ $slot < $sch['pay'] ? 'bg-emerald-100 border-emerald-200 text-emerald-800' : 'bg-white border-gray-200 text-gray-500' }}"
dir="ltr">{{ number_format($amount / 100, 2) }}</span>
@endforeach
</div>
<div class="flex items-center gap-2">
<label class="text-[11px] text-indigo-800 shrink-0">{{ __('يُدفع الآن:') }}</label>
<div class="flex items-center gap-1">
<button type="button" wire:click="setPayInstallments({{ $index }}, {{ $sch['pay'] - 1 }})"
class="w-6 h-6 flex items-center justify-center rounded bg-white border border-indigo-200 text-indigo-700 text-xs font-bold">-</button>
<span class="w-6 text-center text-[11px] font-bold text-indigo-900" dir="ltr">{{ $sch['pay'] }}</span>
<button type="button" wire:click="setPayInstallments({{ $index }}, {{ $sch['pay'] + 1 }})"
class="w-6 h-6 flex items-center justify-center rounded bg-white border border-indigo-200 text-indigo-700 text-xs font-bold">+</button>
</div>
<span class="text-[11px] text-indigo-700">{{ __('من') }} <span dir="ltr">{{ $sch['installments'] }}</span></span>
</div>
</div>
@endif
</div>
@endif
@empty
<div class="text-center py-8 sm:py-12">
<svg class="w-12 h-12 sm:w-16 sm:h-16 text-gray-200 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......@@ -331,6 +385,16 @@ class="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400
<span class="text-gray-800">{{ __('الإجمالي') }}</span>
<span class="text-blue-700" dir="ltr">{{ number_format($cartGrandTotal / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@if($hasInstallmentPlan)
<div class="flex justify-between text-sm sm:text-base font-bold">
<span class="text-indigo-800">{{ __('المطلوب الآن') }}</span>
<span class="text-indigo-700" dir="ltr">{{ number_format($amountDueNow / 100, 2) }} {{ __('ج.م') }}</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-amber-700">{{ __('يُقسَّط على دفعات لاحقة') }}</span>
<span class="text-amber-700 font-bold" dir="ltr">{{ number_format(($cartGrandTotal - $amountDueNow) / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endif
</div>
{{-- Payment Method --}}
......@@ -401,16 +465,16 @@ class="min-w-[44px] min-h-[44px] px-3 bg-blue-600 text-white rounded text-xs hov
@if(count($splitPayments) > 0)
<div class="flex justify-between text-xs pt-1 border-t border-gray-200">
<span class="text-gray-500">{{ __('المدفوع') }}</span>
<span class="{{ $splitTotal === $cartTotal ? 'text-green-600' : 'text-red-600' }} font-bold" dir="ltr">
{{ number_format($splitTotal / 100, 2) }} / {{ number_format($cartTotal / 100, 2) }}
<span class="{{ $splitTotal >= $amountDueNow ? 'text-green-600' : 'text-red-600' }} font-bold" dir="ltr">
{{ number_format($splitTotal / 100, 2) }} / {{ number_format($amountDueNow / 100, 2) }}
</span>
</div>
@endif
</div>
@endif
{{-- Partial Payment (Deposit) Option --}}
@if($allowsDeposit && $paymentMethod !== 'split')
{{-- Partial Payment (Deposit) Option — a plan already says what is due today --}}
@if($allowsDeposit && !$hasInstallmentPlan && $paymentMethod !== 'split')
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model.live="useDeposit"
......@@ -448,7 +512,7 @@ class="w-full px-3 sm:px-4 py-2.5 min-h-[44px] border border-gray-300 rounded-lg
<button wire:click="checkout"
wire:loading.attr="disabled"
wire:target="checkout"
@if($paymentMethod === 'split' && collect($splitPayments)->sum(fn ($sp) => $sp['amount']) !== $cartTotal) disabled @endif
@if($paymentMethod === 'split' && collect($splitPayments)->sum(fn ($sp) => $sp['amount']) < $amountDueNow) disabled @endif
class="w-full px-4 py-3 min-h-[48px] bg-green-600 text-white rounded-lg hover:bg-green-700 active:bg-green-800 active:scale-[0.97] text-xs sm:text-sm font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<span wire:loading.remove wire:target="checkout">
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......
<?php
namespace Tests\Unit;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Services\PricingService;
use Tests\TestCase;
/**
* Two prices for one product, and the schedule you pay it on.
*
* A product can be given a member rate and a non-member rate on the product
* screen, and instalment plans open to one tier or the other. Only the
* registration wizard ever read any of it: the till resolved prices through the
* pricing engine, the engine only knew about `base_prices`, and nothing writes
* a product's member rate there — so every member who bought at reception paid
* the walk-in price and no line on the receipt said so.
*
* These pin the precedence between the two places a price can live, and the
* arithmetic of splitting a price into instalments.
*/
class MemberPricingAndInstalmentsTest extends TestCase
{
// ---- which price wins -------------------------------------------------
public function test_a_member_rate_on_the_product_beats_an_untagged_base_price(): void
{
$product = new Product(['selling_price' => 800000, 'member_price' => 600000]);
$untagged = new BasePrice(['amount' => 800000, 'metadata' => []]);
$this->assertSame(600000, $this->resolve($product, $untagged, 'member'));
}
public function test_a_base_price_tagged_for_this_tier_beats_the_product_column(): void
{
// Someone configured a members' base price deliberately, in the screen
// built for it. Nothing outranks that.
$product = new Product(['selling_price' => 800000, 'member_price' => 600000]);
$tagged = new BasePrice(['amount' => 550000, 'metadata' => ['membership_type' => 'member']]);
$this->assertSame(550000, $this->resolve($product, $tagged, 'member'));
}
public function test_a_non_member_gets_the_non_member_column_not_the_member_one(): void
{
$product = new Product([
'selling_price' => 800000,
'member_price' => 600000,
'non_member_price' => 800000,
]);
$this->assertSame(800000, $this->resolve($product, null, 'non_member'));
$this->assertSame(600000, $this->resolve($product, null, 'member'));
}
public function test_an_unfilled_tier_column_falls_through_to_the_base_price(): void
{
// member_price left blank: this member is not on a member rate, he is
// on whatever price the catalogue was given.
$product = new Product(['selling_price' => 800000, 'non_member_price' => 800000]);
$untagged = new BasePrice(['amount' => 750000, 'metadata' => []]);
$this->assertSame(750000, $this->resolve($product, $untagged, 'member'));
}
public function test_no_configured_price_anywhere_is_a_hard_fail_not_a_guess(): void
{
// selling_price is the catalogue's advertised number, not a configured
// price. Letting it answer here would make "nobody set a price" — which
// is meant to stop the sale — impossible to detect.
$product = new Product(['selling_price' => 800000]);
$this->assertNull($this->resolve($product, null, 'member'));
}
public function test_the_engine_only_claims_a_base_price_row_it_actually_used(): void
{
$product = new Product(['selling_price' => 800000, 'member_price' => 600000]);
$this->assertNull($product->explicitTierPrice('non_member'));
$this->assertSame(600000, $product->explicitTierPrice('member'));
$this->assertSame(800000, $product->priceForTier('non_member'), 'Falls back to the catalogue price.');
}
// ---- the schedule -----------------------------------------------------
public function test_a_schedule_adds_up_to_exactly_the_price(): void
{
// 8,000.00 over three: 266,666 + 266,666 + 266,668 piasters. Rounding
// each slot up instead produced 800,001 — a plan asking for a piaster
// the invoice never billed, which can never reach `completed`.
$schedule = $this->plan(installments: 3)->buildSchedule(800000);
$this->assertSame(800000, array_sum($schedule));
$this->assertCount(3, $schedule);
$this->assertSame([266666, 266666, 266668], array_values($schedule));
}
public function test_a_down_payment_percentage_still_leaves_the_rest_exact(): void
{
$schedule = $this->plan(installments: 3, downPaymentPct: 50)->buildSchedule(800000);
$this->assertSame(800000, array_sum($schedule));
}
public function test_explicit_slot_amounts_are_honoured_and_the_rest_fills_the_gap(): void
{
// The 2,500 first instalment the receptionists actually take, with the
// remaining 5,500 split across two.
$schedule = $this->plan(installments: 3, amounts: [250000, null, null])->buildSchedule(800000);
$this->assertSame([250000, 275000, 275000], array_values($schedule));
$this->assertSame(800000, array_sum($schedule));
}
public function test_a_single_instalment_plan_is_just_the_whole_price(): void
{
$this->assertSame([800000], array_values($this->plan(installments: 1)->buildSchedule(800000)));
}
// ---- helpers ----------------------------------------------------------
private function resolve(Product $product, ?BasePrice $basePrice, string $tier): ?int
{
$method = new \ReflectionMethod(PricingService::class, 'resolveBaseAmount');
$method->setAccessible(true);
return $method->invoke(app(PricingService::class), $product, $basePrice, $tier);
}
private function plan(int $installments, int $downPaymentPct = 0, ?array $amounts = null): ProductInstallmentPlan
{
return new ProductInstallmentPlan([
'membership_tier' => 'any',
'label_ar' => 'خطة',
'installments' => $installments,
'frequency' => 'monthly',
'down_payment_pct' => $downPaymentPct,
'installment_amounts' => $amounts,
'is_active' => true,
]);
}
}
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