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;
return $this->explicitTierPrice($membershipType) ?? $this->selling_price;
}
if ($membershipType === 'non_member' && $this->non_member_price !== null) {
return $this->non_member_price;
}
return $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;
}
......
This diff is collapsed.
......@@ -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,
......
This diff is collapsed.
<?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