Commit b3f127c8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix pricing engine and rebuild discounts around recipes and a picker

Two bugs meant no pricing rule has ever applied correctly:

1. Condition keys never matched. The engine reads min/max/values;
   the wizard wrote min_age/min_children/target_gender and the form
   blade wrote a third set. Ranges saw null bounds and list rules saw
   an empty allow-list, both of which passed, so every rule applied to
   every participant.
2. Percentages were 100x too small. applyAdjustment divides by 10000
   (basis points) but both screens stored a plain percent, so "20%"
   discounted 0.2%.

They masked each other, which is why the symptom looked like a broken
engine rather than two bugs — and why everyone moved to the untyped
super-admin price override instead.

Engine
- ConditionSchema is now the single owner of the conditions vocabulary;
  builder, engine, simulator and migration all read keys from it.
- Percent handles all basis-point conversion; nothing else touches the
  raw column.
- evaluateInList fails closed instead of treating an empty allow-list
  as "match everyone".
- custom rules no longer auto-apply; they are picker-only.
- enrollment_timing honours days_before_start (fails closed without a
  program start date instead of silently passing).
- Global discount cap reads system_settings rather than a hardcoded
  constant with a TODO.
- New: explain(), audience(), wouldApply(), and role-capped manual
  discounts.

Per-branch
- pricing_rule_branches pivot so one rule targets many branches,
  instead of one near-identical row per branch that drifts apart.

Stacking
- is_stackable now defaults to false; best-of-one is the normal case
  and stacking is an explicit opt-in.

Authoring
- The five-step column editor becomes a recipe gallery plus an Arabic
  sentence, with a live simulator on a real participant and an audience
  count that warns when a rule would hit everyone. Saving a
  conditionless rule is refused.

Checkout
- ManagesDiscounts trait plus <x-pricing.discount-picker>: branch-scoped,
  searchable, pinned favourites, replace-vs-stack inline, blocked rows
  show why. Wired into CollectPaymentWizard renewals; discount names are
  frozen onto invoice.metadata so receipts survive later rule changes.
- NewRegistrationWizard now prices through the engine using a
  provisional context built from the form, since the participant row
  does not exist yet. The step-4 guard still checks the base price, so a
  100% discount is not mistaken for an unpriced program.

Migration
- Rewrites conditions onto the canonical keys and scales percentages to
  basis points. Rules whose conditions cannot be mapped confidently are
  deactivated rather than guessed, with the old JSON kept in
  metadata.legacy_conditions.

Also fixes list and coupon views that rendered the raw column (a 20%
rule would have displayed as 2000%), and adds the [x-cloak] CSS rule
that was missing app-wide.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 94f464d7
<?php
namespace App\Domain\Pricing\Concerns;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Enums\ManualDiscountReason;
use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Pricing\Services\DiscountCandidate;
use App\Domain\Pricing\Services\PricingService;
use Illuminate\Database\Eloquent\Model;
/**
* Drop-in discount picker state for any checkout Livewire component.
*
* The engine decides; this trait only carries the receptionist's choices.
* Nothing here re-derives eligibility — that would let the till and the
* invoice disagree about the price.
*/
trait ManagesDiscounts
{
/** @var array<int, array> Rendered candidates from PricingService::explain(). */
public array $discountCandidates = [];
/** @var array<int> Rule ids the operator explicitly turned on. */
public array $selectedDiscountIds = [];
/** @var array<int> Rule ids the engine applied that the operator turned off. */
public array $declinedDiscountIds = [];
public string $manualDiscountAmount = '';
public string $manualDiscountReason = '';
public string $manualDiscountNote = '';
public ?string $manualDiscountError = null;
public int $discountBaseAmount = 0;
public int $discountFinalAmount = 0;
/**
* Ask the engine what this participant may have, at this branch, today.
*/
public function loadDiscounts(
Model $priceable,
?Participant $participant,
?int $branchId,
array $extraContext = []
): void {
$explained = app(PricingService::class)->explain(
priceable: $priceable,
participant: $participant,
branchId: $branchId,
extraContext: $extraContext,
actorRoleLevel: $this->actorRoleLevel(),
);
$this->discountCandidates = array_map(
fn (DiscountCandidate $c) => $c->toArray(),
$explained['candidates']
);
$this->discountBaseAmount = $explained['result']->baseAmount;
$this->discountFinalAmount = $explained['result']->finalAmount;
// Auto-applied rules start selected unless the operator already declined them.
foreach ($this->discountCandidates as $candidate) {
if ($candidate['state'] === DiscountCandidate::APPLIED
&& ! in_array($candidate['rule_id'], $this->declinedDiscountIds, true)
&& ! in_array($candidate['rule_id'], $this->selectedDiscountIds, true)) {
$this->selectedDiscountIds[] = $candidate['rule_id'];
}
}
}
public function applyDiscount(int $ruleId): void
{
$candidate = $this->findCandidate($ruleId);
if (! $candidate) {
return;
}
if ($candidate['state'] === DiscountCandidate::BLOCKED) {
return;
}
if ($candidate['state'] === DiscountCandidate::NEEDS_APPROVAL) {
$this->dispatch('discount-needs-approval', ruleId: $ruleId, name: $candidate['name']);
return;
}
// Non-stackable discounts replace whatever non-stackable one is on.
if (! $candidate['is_stackable']) {
$this->selectedDiscountIds = array_values(array_filter(
$this->selectedDiscountIds,
fn ($id) => ($this->findCandidate($id)['is_stackable'] ?? true) === true
));
}
if (! in_array($ruleId, $this->selectedDiscountIds, true)) {
$this->selectedDiscountIds[] = $ruleId;
}
$this->declinedDiscountIds = array_values(array_diff($this->declinedDiscountIds, [$ruleId]));
$this->dispatch('discounts-changed');
}
public function removeDiscount(int $ruleId): void
{
// Declining a discount the participant is entitled to is a separate act
// from applying one, and is gated separately.
if (! auth()->user()?->can('pricing.discount_remove')) {
$this->dispatch('discount-remove-denied');
return;
}
$this->selectedDiscountIds = array_values(array_diff($this->selectedDiscountIds, [$ruleId]));
if (! in_array($ruleId, $this->declinedDiscountIds, true)) {
$this->declinedDiscountIds[] = $ruleId;
}
$this->dispatch('discounts-changed');
}
/**
* Validate a manual discount against the actor's ceiling before it is used.
*/
public function validateManualDiscount(): bool
{
$this->manualDiscountError = null;
if (! ManualDiscountReason::tryFrom($this->manualDiscountReason)) {
$this->manualDiscountError = __('اختر سبب الخصم');
return false;
}
$reason = ManualDiscountReason::from($this->manualDiscountReason);
if ($reason->requiresNote() && trim($this->manualDiscountNote) === '') {
$this->manualDiscountError = __('هذا السبب يتطلب كتابة ملاحظة');
return false;
}
$piasters = (int) round((float) $this->manualDiscountAmount * 100);
$check = app(PricingService::class)->checkManualDiscount(
$this->discountBaseAmount,
$piasters,
$this->actorRoleLevel()
);
if (! $check['allowed']) {
$this->manualDiscountError = $check['message'];
return false;
}
return true;
}
public function clearManualDiscount(): void
{
$this->manualDiscountAmount = '';
$this->manualDiscountReason = '';
$this->manualDiscountNote = '';
$this->manualDiscountError = null;
}
/** Total discount in piasters from the operator's current selection. */
public function selectedDiscountTotal(): int
{
$total = 0;
foreach ($this->selectedDiscountIds as $id) {
$total += (int) ($this->findCandidate($id)['discount'] ?? 0);
}
if ($this->manualDiscountAmount !== '') {
$total += (int) round((float) $this->manualDiscountAmount * 100);
}
return min($total, $this->discountBaseAmount);
}
/** Snapshot for the invoice — frozen names, not ids, so receipts stay readable. */
public function discountSnapshot(): array
{
$lines = [];
foreach ($this->selectedDiscountIds as $id) {
if ($c = $this->findCandidate($id)) {
$lines[] = [
'rule_id' => $c['rule_id'],
'name' => $c['name'],
'discount' => (int) $c['discount'],
];
}
}
if ($this->manualDiscountAmount !== '' && $this->manualDiscountReason !== '') {
$lines[] = [
'rule_id' => null,
'name' => ManualDiscountReason::from($this->manualDiscountReason)->label(),
'discount' => (int) round((float) $this->manualDiscountAmount * 100),
'note' => $this->manualDiscountNote ?: null,
'by' => auth()->id(),
];
}
return $lines;
}
public function getManualReasonOptionsProperty(): array
{
return ManualDiscountReason::options();
}
public function getManualDiscountCapProperty(): int
{
return app(PricingService::class)->manualDiscountCap($this->actorRoleLevel());
}
protected function findCandidate(int $ruleId): ?array
{
foreach ($this->discountCandidates as $candidate) {
if ($candidate['rule_id'] === $ruleId) {
return $candidate;
}
}
return null;
}
protected function actorRoleLevel(): int
{
$user = auth()->user();
if (! $user) {
return 0;
}
if ($user->is_super_admin) {
return 100;
}
return (int) ($user->roles()->max('level') ?? 0);
}
}
<?php
namespace App\Domain\Pricing\Enums;
/**
* Fixed reason list for the governed manual discount.
*
* Replaces the free-text override reason, which could not be reported on.
*/
enum ManualDiscountReason: string
{
case Hardship = 'hardship';
case Staff = 'staff';
case Goodwill = 'goodwill';
case Complaint = 'complaint';
case Management = 'management';
case Correction = 'correction';
public function label(): string
{
return match ($this) {
self::Hardship => 'حالة اجتماعية',
self::Staff => 'موظف أو قريب موظف',
self::Goodwill => 'بادرة حسن نية',
self::Complaint => 'تعويض عن شكوى',
self::Management => 'قرار إداري',
self::Correction => 'تصحيح خطأ تسعير',
};
}
/** Reasons that always need a written note regardless of amount. */
public function requiresNote(): bool
{
return in_array($this, [self::Management, self::Correction, self::Complaint]);
}
public static function options(): array
{
return array_map(
fn (self $c) => ['value' => $c->value, 'label' => $c->label(), 'note' => $c->requiresNote()],
self::cases()
);
}
}
...@@ -37,6 +37,13 @@ class PricingRule extends Model ...@@ -37,6 +37,13 @@ class PricingRule extends Model
'usage_count', 'usage_count',
'created_by', 'created_by',
'metadata', 'metadata',
'is_pinned',
'pin_icon',
'display_order',
'recipe_key',
'is_manual',
'requires_approval',
'min_role_level',
]; ];
protected $casts = [ protected $casts = [
...@@ -53,8 +60,35 @@ class PricingRule extends Model ...@@ -53,8 +60,35 @@ class PricingRule extends Model
'effective_from' => 'date', 'effective_from' => 'date',
'effective_to' => 'date', 'effective_to' => 'date',
'metadata' => 'array', 'metadata' => 'array',
'is_pinned' => 'boolean',
'display_order' => 'integer',
'is_manual' => 'boolean',
'requires_approval' => 'boolean',
'min_role_level' => 'integer',
]; ];
/**
* Percentage adjustments are stored as basis points. Nothing outside the
* model should touch the raw column — read and write through these.
*/
public function getPercentAttribute(): float
{
return \App\Domain\Pricing\Support\Percent::fromBasisPoints((int) $this->adjustment_value);
}
public function setPercentAttribute(float|int|string $value): void
{
$this->attributes['adjustment_value'] = \App\Domain\Pricing\Support\Percent::toBasisPoints($value);
}
public function isPercentage(): bool
{
return in_array($this->adjustment_type, [
AdjustmentType::PercentageDiscount,
AdjustmentType::PercentageIncrease,
], true);
}
// ─── Relationships ──────────────────────────────────────────────── // ─── Relationships ────────────────────────────────────────────────
public function branch(): BelongsTo public function branch(): BelongsTo
...@@ -67,6 +101,37 @@ public function creator(): BelongsTo ...@@ -67,6 +101,37 @@ public function creator(): BelongsTo
return $this->belongsTo(\App\Models\User::class, 'created_by'); return $this->belongsTo(\App\Models\User::class, 'created_by');
} }
/**
* A rule targets many branches through the pivot. The legacy single
* `branch_id` column still works and is treated as one more target.
*/
public function branches(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(
\App\Domain\Identity\Models\Branch::class,
'pricing_rule_branches',
'pricing_rule_id',
'branch_id'
)->withTimestamps();
}
/** Branch ids this rule targets, from both the pivot and the legacy column. */
public function targetBranchIds(): array
{
$ids = $this->branches->pluck('id')->all();
if ($this->branch_id && ! in_array($this->branch_id, $ids, true)) {
$ids[] = $this->branch_id;
}
return $ids;
}
public function isAcademyWide(): bool
{
return empty($this->targetBranchIds());
}
// ─── Scopes ─────────────────────────────────────────────────────── // ─── Scopes ───────────────────────────────────────────────────────
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
...@@ -88,11 +153,35 @@ public function scopeEffectiveOn(Builder $query, $date): Builder ...@@ -88,11 +153,35 @@ public function scopeEffectiveOn(Builder $query, $date): Builder
public function scopeForBranch(Builder $query, ?int $branchId): Builder public function scopeForBranch(Builder $query, ?int $branchId): Builder
{ {
return $query->where(function (Builder $q) use ($branchId) { return $query->where(function (Builder $q) use ($branchId) {
$q->whereNull('branch_id') // Academy-wide = no legacy FK AND no pivot targeting.
->orWhere('branch_id', $branchId); $q->where(function (Builder $sq) {
$sq->whereNull('branch_id')->whereNotExists(function ($e) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id');
});
});
if ($branchId) {
$q->orWhere('branch_id', $branchId)
->orWhereExists(function ($e) use ($branchId) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id')
->where('pricing_rule_branches.branch_id', $branchId);
});
}
}); });
} }
public function scopePinned(Builder $query): Builder
{
return $query->where('is_pinned', true)->orderBy('display_order');
}
public function scopeAutomatic(Builder $query): Builder
{
return $query->where('is_manual', false);
}
public function scopeForTarget(Builder $query, ?string $type, ?int $id = null): Builder public function scopeForTarget(Builder $query, ?string $type, ?int $id = null): Builder
{ {
return $query->where(function (Builder $q) use ($type, $id) { return $query->where(function (Builder $q) use ($type, $id) {
......
<?php
namespace App\Domain\Pricing\Services;
/**
* One row in the discount picker.
*
* The picker never re-derives eligibility — it renders exactly what the engine
* decided, including WHY something is unavailable. A discount that silently
* fails to appear generates a phone call to the office.
*/
class DiscountCandidate
{
public const APPLIED = 'applied'; // auto-qualified, currently applied
public const AVAILABLE = 'available'; // qualifies; replaces the applied one
public const STACKS = 'stacks'; // qualifies; adds on top
public const NEEDS_APPROVAL = 'needs_approval'; // above the actor's ceiling
public const BLOCKED = 'blocked'; // cannot apply, with a reason
public function __construct(
public readonly int $ruleId,
public readonly string $name,
public readonly string $state,
public readonly ?string $icon = null,
public readonly ?string $valueLabel = null,
public readonly int $discountAmount = 0,
public readonly bool $isStackable = false,
public readonly bool $isPinned = false,
public readonly bool $isManual = false,
public readonly int $displayOrder = 0,
public readonly ?string $reason = null,
) {}
public function isSelectable(): bool
{
return in_array($this->state, [self::AVAILABLE, self::STACKS, self::NEEDS_APPROVAL], true);
}
/** Arabic group heading in the picker. */
public function groupLabel(): string
{
return match ($this->state) {
self::APPLIED => 'مؤهل تلقائياً',
self::AVAILABLE => 'متاح',
self::STACKS => 'يُجمع مع غيره',
self::NEEDS_APPROVAL => 'يحتاج اعتماد',
default => 'غير متاح',
};
}
public function toArray(): array
{
return [
'rule_id' => $this->ruleId,
'name' => $this->name,
'state' => $this->state,
'icon' => $this->icon,
'value_label' => $this->valueLabel,
'discount' => $this->discountAmount,
'is_stackable' => $this->isStackable,
'is_pinned' => $this->isPinned,
'is_manual' => $this->isManual,
'display_order' => $this->displayOrder,
'reason' => $this->reason,
'group_label' => $this->groupLabel(),
];
}
}
...@@ -7,7 +7,10 @@ ...@@ -7,7 +7,10 @@
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Models\PricingRule; use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Pricing\Models\Promotion; use App\Domain\Pricing\Models\Promotion;
use App\Domain\Pricing\Support\ConditionSchema;
use App\Domain\Pricing\Support\Percent;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\SystemSetting;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
...@@ -34,13 +37,21 @@ public function calculate( ...@@ -34,13 +37,21 @@ public function calculate(
?Participant $participant = null, ?Participant $participant = null,
?int $branchId = null, ?int $branchId = null,
?string $couponCode = null, ?string $couponCode = null,
?string $date = null ?string $date = null,
array $extraContext = [],
?array $contextOverride = null
): PriceResult { ): PriceResult {
$date ??= now()->toDateString(); $date ??= now()->toDateString();
$branchId ??= $this->resolveParticipantBranch($participant); $branchId ??= $this->resolveParticipantBranch($participant);
// ─── Step 1: Find base price (fail if none exists) ──────────── // ─── Step 1: Find base price (fail if none exists) ────────────
$basePrice = $this->findBasePrice($priceable, $branchId, $date, $participant); $basePrice = $this->findBasePrice(
$priceable,
$branchId,
$date,
$participant,
$contextOverride['membership_type'] ?? null
);
if (!$basePrice) { if (!$basePrice) {
throw new DomainException('لا يوجد سعر محدد'); throw new DomainException('لا يوجد سعر محدد');
} }
...@@ -54,11 +65,16 @@ public function calculate( ...@@ -54,11 +65,16 @@ public function calculate(
$rules = $this->gatherApplicableRules($priceable, $branchId, $date); $rules = $this->gatherApplicableRules($priceable, $branchId, $date);
// ─── Step 3: Evaluate conditions against participant context ── // ─── Step 3: Evaluate conditions against participant context ──
if ($participant) { // A provisional context lets new-registration price correctly BEFORE the
$context = $this->buildParticipantContext($participant); // participant row exists — otherwise the busiest flow cannot use the engine.
if ($contextOverride !== null) {
$context = array_merge($contextOverride, $extraContext);
$rules = $rules->filter(fn (PricingRule $rule) => $this->evaluateConditions($rule, $context));
} elseif ($participant) {
$context = array_merge($this->buildParticipantContext($participant), $extraContext);
$rules = $rules->filter(fn (PricingRule $rule) => $this->evaluateConditions($rule, $context)); $rules = $rules->filter(fn (PricingRule $rule) => $this->evaluateConditions($rule, $context));
} else { } else {
// No participant: only rules without conditions apply // No participant and no context: only unconditional rules apply
$rules = $rules->filter(fn (PricingRule $rule) => empty($rule->conditions)); $rules = $rules->filter(fn (PricingRule $rule) => empty($rule->conditions));
} }
...@@ -153,8 +169,13 @@ public function calculate( ...@@ -153,8 +169,13 @@ public function calculate(
// ─── Step 1: Base Price Resolution ──────────────────────────────────── // ─── Step 1: Base Price Resolution ────────────────────────────────────
private function findBasePrice(Model $priceable, ?int $branchId, string $date, ?Participant $participant = null): ?BasePrice private function findBasePrice(
{ Model $priceable,
?int $branchId,
string $date,
?Participant $participant = null,
?string $membershipTypeOverride = null
): ?BasePrice {
$query = BasePrice::where('priceable_type', $priceable->getMorphClass()) $query = BasePrice::where('priceable_type', $priceable->getMorphClass())
->where('priceable_id', $priceable->getKey()) ->where('priceable_id', $priceable->getKey())
->where('is_active', true) ->where('is_active', true)
...@@ -164,8 +185,10 @@ private function findBasePrice(Model $priceable, ?int $branchId, string $date, ? ...@@ -164,8 +185,10 @@ private function findBasePrice(Model $priceable, ?int $branchId, string $date, ?
->orWhere('effective_to', '>=', $date); ->orWhere('effective_to', '>=', $date);
}); });
// Resolve membership type from participant // Membership type: explicit override (new registration, no row yet) wins.
$membershipType = $participant?->membership_type?->value ?? 'non_member'; $membershipType = $membershipTypeOverride
?? $participant?->membership_type?->value
?? 'non_member';
// Try membership-type-specific price first, then fallback to any // Try membership-type-specific price first, then fallback to any
$withMembership = fn ($q) => (clone $q)->where('metadata->membership_type', $membershipType); $withMembership = fn ($q) => (clone $q)->where('metadata->membership_type', $membershipType);
...@@ -216,10 +239,24 @@ private function gatherApplicableRules(Model $priceable, ?int $branchId, string ...@@ -216,10 +239,24 @@ private function gatherApplicableRules(Model $priceable, ?int $branchId, string
$q->whereNull('effective_to') $q->whereNull('effective_to')
->orWhere('effective_to', '>=', $date); ->orWhere('effective_to', '>=', $date);
}) })
// Manual/approval discounts are chosen in the picker, never auto-applied.
->where('is_manual', false)
->where(function ($q) use ($branchId) { ->where(function ($q) use ($branchId) {
$q->whereNull('branch_id'); // Academy-wide = no single-branch FK AND no pivot targeting at all.
$q->where(function ($sq) {
$sq->whereNull('branch_id')->whereNotExists(function ($e) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id');
});
});
if ($branchId) { if ($branchId) {
$q->orWhere('branch_id', $branchId); $q->orWhere('branch_id', $branchId)
->orWhereExists(function ($e) use ($branchId) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id')
->where('pricing_rule_branches.branch_id', $branchId);
});
} }
}) })
->where(function ($q) use ($priceable) { ->where(function ($q) use ($priceable) {
...@@ -309,11 +346,13 @@ private function evaluateConditions(PricingRule $rule, array $context): bool ...@@ -309,11 +346,13 @@ private function evaluateConditions(PricingRule $rule, array $context): bool
\App\Domain\Pricing\Enums\PricingRuleType::EnrollmentVolume => $this->evaluateRange($context['enrollment_count'], $conditions), \App\Domain\Pricing\Enums\PricingRuleType::EnrollmentVolume => $this->evaluateRange($context['enrollment_count'], $conditions),
\App\Domain\Pricing\Enums\PricingRuleType::Gender => $this->evaluateInList($context['gender'], $conditions), \App\Domain\Pricing\Enums\PricingRuleType::Gender => $this->evaluateInList($context['gender'], $conditions),
\App\Domain\Pricing\Enums\PricingRuleType::Branch => $this->evaluateInList($context['branch_id'], $conditions), \App\Domain\Pricing\Enums\PricingRuleType::Branch => $this->evaluateInList($context['branch_id'], $conditions),
\App\Domain\Pricing\Enums\PricingRuleType::EnrollmentTiming => $this->evaluateTimingCondition($conditions), \App\Domain\Pricing\Enums\PricingRuleType::EnrollmentTiming => $this->evaluateTimingCondition($conditions, $context),
\App\Domain\Pricing\Enums\PricingRuleType::Seasonal => $this->evaluateSeasonalCondition($conditions), \App\Domain\Pricing\Enums\PricingRuleType::Seasonal => $this->evaluateSeasonalCondition($conditions),
\App\Domain\Pricing\Enums\PricingRuleType::DayTime => $this->evaluateDayTimeCondition($conditions), \App\Domain\Pricing\Enums\PricingRuleType::DayTime => $this->evaluateDayTimeCondition($conditions),
\App\Domain\Pricing\Enums\PricingRuleType::Loyalty => $this->evaluateRange($context['membership_duration_months'], $conditions), \App\Domain\Pricing\Enums\PricingRuleType::Loyalty => $this->evaluateRange($context['membership_duration_months'], $conditions),
\App\Domain\Pricing\Enums\PricingRuleType::Custom => true, // Custom rules always pass (admin assigns manually) // Custom/manual rules are chosen explicitly in the picker — they must
// never auto-apply, or every participant silently receives them.
\App\Domain\Pricing\Enums\PricingRuleType::Custom => false,
}; };
} }
...@@ -338,15 +377,18 @@ private function evaluateRange(?int $value, array $conditions): bool ...@@ -338,15 +377,18 @@ private function evaluateRange(?int $value, array $conditions): bool
private function evaluateInList(mixed $value, array $conditions): bool private function evaluateInList(mixed $value, array $conditions): bool
{ {
$allowed = $conditions['values'] ?? $conditions['in'] ?? []; $allowed = $conditions['values'] ?? [];
if (empty($allowed)) {
return true; // Fail CLOSED. An empty allow-list used to mean "match everyone", which
// turned every mis-authored rule into an academy-wide discount.
if (empty($allowed) || $value === null) {
return false;
} }
return in_array($value, $allowed); return in_array($value, $allowed);
} }
private function evaluateTimingCondition(array $conditions): bool private function evaluateTimingCondition(array $conditions, array $context = []): bool
{ {
$now = now(); $now = now();
...@@ -357,8 +399,17 @@ private function evaluateTimingCondition(array $conditions): bool ...@@ -357,8 +399,17 @@ private function evaluateTimingCondition(array $conditions): bool
return false; return false;
} }
// days_before_start requires enrollment context — pass if condition is set // Early-bird: only satisfiable when the caller supplied the program start
// (caller enriches context when doing enrollment-specific pricing) // date. Without it the condition fails closed rather than silently passing.
if (isset($conditions['days_before_start'])) {
$startsOn = $context['program_starts_on'] ?? null;
if (! $startsOn) {
return false;
}
return $now->lte(\Carbon\Carbon::parse($startsOn)->subDays((int) $conditions['days_before_start']));
}
return true; return true;
} }
...@@ -533,9 +584,297 @@ public function incrementUsage( ...@@ -533,9 +584,297 @@ public function incrementUsage(
private function getMaxDiscountPercent(): int private function getMaxDiscountPercent(): int
{ {
// TODO: Read from academy system_settings when that module is built $value = (int) SystemSetting::get('pricing_max_discount_percent', self::DEFAULT_MAX_DISCOUNT_PERCENT);
// For now, use the default constant
return self::DEFAULT_MAX_DISCOUNT_PERCENT; return ($value > 0 && $value <= 100) ? $value : self::DEFAULT_MAX_DISCOUNT_PERCENT;
}
// ─── Picker & Simulator ───────────────────────────────────────────────
/**
* Everything the discount picker and the rule simulator need.
*
* Unlike calculate(), this returns EVERY candidate the branch offers — including
* the ones that do not qualify — each with a state and, when unavailable, a
* human reason. A discount that silently fails to appear generates a phone call.
*
* @return array{result: PriceResult, candidates: DiscountCandidate[]}
*/
public function explain(
Model $priceable,
?Participant $participant = null,
?int $branchId = null,
?string $couponCode = null,
?string $date = null,
array $extraContext = [],
?int $actorRoleLevel = null,
?array $contextOverride = null
): array {
$date ??= now()->toDateString();
$branchId ??= $this->resolveParticipantBranch($participant);
$result = $this->calculate($priceable, $participant, $branchId, $couponCode, $date, $extraContext, $contextOverride);
$appliedIds = collect($result->appliedRules)->pluck('rule_id')->all();
if ($contextOverride !== null) {
$context = array_merge($contextOverride, $extraContext);
} elseif ($participant) {
$context = array_merge($this->buildParticipantContext($participant), $extraContext);
} else {
$context = [];
}
$candidates = [];
foreach ($this->gatherPickerRules($priceable, $branchId, $date) as $rule) {
$candidates[] = $this->describeCandidate(
$rule,
$result,
$appliedIds,
$context,
$participant !== null || $contextOverride !== null,
$actorRoleLevel
);
}
// Pinned first, then explicit order, then strongest discount.
usort($candidates, function (DiscountCandidate $a, DiscountCandidate $b) {
// pinned first, then display_order ASC, then strongest discount first
return [$b->isPinned, $a->displayOrder, -$a->discountAmount]
<=> [$a->isPinned, $b->displayOrder, -$b->discountAmount];
});
return ['result' => $result, 'candidates' => $candidates];
}
/**
* Candidate rules for the picker: same branch/target/date filtering as the
* engine, but WITHOUT dropping manual rules or exhausted ones — the picker
* shows those with a reason instead of hiding them.
*/
private function gatherPickerRules(Model $priceable, ?int $branchId, string $date): Collection
{
return PricingRule::where('is_active', true)
->where(function ($q) use ($date) {
$q->whereNull('effective_from')->orWhere('effective_from', '<=', $date);
})
->where(function ($q) use ($date) {
$q->whereNull('effective_to')->orWhere('effective_to', '>=', $date);
})
->where(function ($q) use ($branchId) {
$q->where(function ($sq) {
$sq->whereNull('branch_id')->whereNotExists(function ($e) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id');
});
});
if ($branchId) {
$q->orWhere('branch_id', $branchId)
->orWhereExists(function ($e) use ($branchId) {
$e->selectRaw('1')->from('pricing_rule_branches')
->whereColumn('pricing_rule_branches.pricing_rule_id', 'pricing_rules.id')
->where('pricing_rule_branches.branch_id', $branchId);
});
}
})
->where(function ($q) use ($priceable) {
$q->where(function ($sq) {
$sq->whereNull('target_type')->whereNull('target_id');
})
->orWhere(function ($sq) use ($priceable) {
$sq->where('target_type', $priceable->getMorphClass())
->where('target_id', $priceable->getKey());
})
->orWhere(function ($sq) use ($priceable) {
$sq->where('target_type', $priceable->getMorphClass())->whereNull('target_id');
});
})
->orderByDesc('is_pinned')
->orderBy('display_order')
->get();
}
private function describeCandidate(
PricingRule $rule,
PriceResult $result,
array $appliedIds,
array $context,
bool $hasParticipant,
?int $actorRoleLevel
): DiscountCandidate {
$common = [
'ruleId' => $rule->id,
'name' => $rule->name_ar,
'icon' => $rule->pin_icon,
'valueLabel' => $this->adjustmentLabel($rule),
'isStackable' => (bool) $rule->is_stackable,
'isPinned' => (bool) $rule->is_pinned,
'isManual' => (bool) $rule->is_manual,
'displayOrder' => (int) $rule->display_order,
];
// Already applied by the engine.
if (in_array($rule->id, $appliedIds, true)) {
$line = collect($result->appliedRules)->firstWhere('rule_id', $rule->id);
return new DiscountCandidate(
...$common,
state: DiscountCandidate::APPLIED,
discountAmount: (int) ($line['discount'] ?? 0),
);
}
// Exhausted.
if ($rule->usage_limit !== null && $rule->usage_count >= $rule->usage_limit) {
return new DiscountCandidate(
...$common,
state: DiscountCandidate::BLOCKED,
reason: 'استُنفد الحد الأقصى للاستخدام',
);
}
// Manual discounts: available only up to the actor's ceiling.
if ($rule->is_manual) {
$needsApproval = $rule->requires_approval
|| ($rule->min_role_level !== null && ($actorRoleLevel ?? 0) < $rule->min_role_level);
return new DiscountCandidate(
...$common,
state: $needsApproval ? DiscountCandidate::NEEDS_APPROVAL : DiscountCandidate::AVAILABLE,
reason: $needsApproval ? 'يتجاوز صلاحيتك — يفتح طلب اعتماد' : null,
);
}
if (! $hasParticipant) {
return new DiscountCandidate(
...$common,
state: DiscountCandidate::BLOCKED,
reason: 'يتطلب اختيار مشترك',
);
}
// Does the participant actually qualify?
if (! $this->evaluateConditions($rule, $context)) {
return new DiscountCandidate(
...$common,
state: DiscountCandidate::BLOCKED,
reason: 'المشترك غير مستوفٍ للشروط: ' . ConditionSchema::describe($rule->rule_type, $rule->conditions ?? []),
);
}
// Qualifies but was not applied — it lost the non-stackable contest.
return new DiscountCandidate(
...$common,
state: $rule->is_stackable ? DiscountCandidate::STACKS : DiscountCandidate::AVAILABLE,
discountAmount: $result->baseAmount - $this->applyAdjustment($result->baseAmount, $result->baseAmount, $rule),
reason: $rule->is_stackable ? null : 'لا يُجمع — سيحل محل الخصم المطبق',
);
}
private function adjustmentLabel(PricingRule $rule): string
{
return match ($rule->adjustment_type) {
AdjustmentType::PercentageDiscount => '−' . Percent::label($rule->adjustment_value),
AdjustmentType::PercentageIncrease => '+' . Percent::label($rule->adjustment_value),
AdjustmentType::FixedDiscount => '−' . number_format($rule->adjustment_value / 100, 2) . ' ج.م',
AdjustmentType::FixedPrice => number_format($rule->adjustment_value / 100, 2) . ' ج.م',
};
}
/**
* How many participants a rule would currently hit — shown live in the builder
* so "this applies to 340 people" is caught before saving, not after.
*
* @return array{matched: int, total: int}
*/
public function audience(PricingRule $rule, ?int $branchId = null): array
{
$query = Participant::query();
if ($branchId) {
$query->where('branch_id', $branchId);
}
$total = (clone $query)->count();
if (empty($rule->conditions) && $rule->rule_type !== \App\Domain\Pricing\Enums\PricingRuleType::Custom) {
return ['matched' => $total, 'total' => $total];
}
$matched = 0;
$query->with(['person', 'guardians'])->chunkById(200, function ($chunk) use ($rule, &$matched) {
foreach ($chunk as $participant) {
if ($this->evaluateConditions($rule, $this->buildParticipantContext($participant))) {
$matched++;
}
}
});
return ['matched' => $matched, 'total' => $total];
}
/**
* Does this (possibly unsaved) rule match this participant right now?
*
* Public so the builder can preview a draft before it is persisted.
*/
public function wouldApply(PricingRule $rule, Participant $participant, array $extraContext = []): bool
{
$context = array_merge($this->buildParticipantContext($participant), $extraContext);
return $this->evaluateConditions($rule, $context);
}
// ─── Governed Manual Discount ─────────────────────────────────────────
/**
* The discount ceiling for a role level, from system_settings.
*
* Replaces the super-admin free-text price override, which was the only
* discount path that actually worked and therefore the one that got abused.
*/
public function manualDiscountCap(?int $roleLevel): int
{
$level = $roleLevel ?? 0;
if ($level >= 90) {
return (int) SystemSetting::get('pricing_manual_cap_owner', 100);
}
if ($level >= 70) {
return (int) SystemSetting::get('pricing_manual_cap_manager', 25);
}
return (int) SystemSetting::get('pricing_manual_cap_staff', 10);
}
/**
* Validate a manual discount against the actor's ceiling.
*
* @return array{allowed: bool, requires_approval: bool, cap: int, message: ?string}
*/
public function checkManualDiscount(int $baseAmount, int $discountAmount, ?int $roleLevel): array
{
$cap = $this->manualDiscountCap($roleLevel);
if ($discountAmount <= 0 || $baseAmount <= 0) {
return ['allowed' => false, 'requires_approval' => false, 'cap' => $cap,
'message' => 'قيمة الخصم غير صالحة'];
}
if ($discountAmount > $baseAmount) {
return ['allowed' => false, 'requires_approval' => false, 'cap' => $cap,
'message' => 'الخصم أكبر من قيمة الصنف'];
}
$percent = (int) floor($discountAmount * 100 / $baseAmount);
if ($percent > $cap) {
return ['allowed' => false, 'requires_approval' => true, 'cap' => $cap,
'message' => "الخصم {$percent}% يتجاوز صلاحيتك ({$cap}%) — يلزم اعتماد"];
}
return ['allowed' => true, 'requires_approval' => false, 'cap' => $cap, 'message' => null];
} }
// ─── Helpers ────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────
......
<?php
namespace App\Domain\Pricing\Support;
use App\Domain\Pricing\Enums\PricingRuleType;
/**
* The single owner of the `pricing_rules.conditions` vocabulary.
*
* The authoring UI, the engine, the simulator and the migration all read their
* keys from here. Previously each had its own spelling (min_age vs min vs
* min_family_size), so the engine silently matched every participant.
*
* NOTHING may invent a condition key outside this class.
*/
final class ConditionSchema
{
public const KIND_RANGE = 'range';
public const KIND_LIST = 'list';
public const KIND_TIMING = 'timing';
public const KIND_MONTHS = 'months';
public const KIND_SCHED = 'schedule';
public const KIND_NONE = 'none';
/**
* Canonical shape per rule type.
*
* keys — exactly what may appear in the conditions JSONB
* unit — Arabic unit shown beside range inputs
*/
private const SCHEMA = [
'age' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'سنة'],
'membership_duration' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'شهر'],
'family_size' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'أبناء'],
'sibling_order' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'الترتيب'],
'enrollment_volume' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'برنامج'],
'loyalty' => ['kind' => self::KIND_RANGE, 'keys' => ['min', 'max'], 'unit' => 'شهر'],
'classification' => ['kind' => self::KIND_LIST, 'keys' => ['values'], 'unit' => null],
'gender' => ['kind' => self::KIND_LIST, 'keys' => ['values'], 'unit' => null],
'branch' => ['kind' => self::KIND_LIST, 'keys' => ['values'], 'unit' => null],
'enrollment_timing' => ['kind' => self::KIND_TIMING, 'keys' => ['before_date', 'after_date', 'days_before_start'], 'unit' => null],
'seasonal' => ['kind' => self::KIND_MONTHS, 'keys' => ['months'], 'unit' => null],
'day_time' => ['kind' => self::KIND_SCHED, 'keys' => ['days', 'after_hour', 'before_hour'], 'unit' => null],
'custom' => ['kind' => self::KIND_NONE, 'keys' => [], 'unit' => null],
];
/** Allowed values for list-kind rule types. */
public const CLASSIFICATIONS = ['regular', 'vip', 'scholarship', 'staff_child', 'trial'];
public const GENDERS = ['male', 'female'];
public const WEEKDAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
public static function kind(PricingRuleType|string $type): string
{
$key = $type instanceof PricingRuleType ? $type->value : $type;
return self::SCHEMA[$key]['kind'] ?? self::KIND_NONE;
}
public static function keys(PricingRuleType|string $type): array
{
$key = $type instanceof PricingRuleType ? $type->value : $type;
return self::SCHEMA[$key]['keys'] ?? [];
}
public static function unit(PricingRuleType|string $type): ?string
{
$key = $type instanceof PricingRuleType ? $type->value : $type;
return self::SCHEMA[$key]['unit'] ?? null;
}
/**
* Strip anything not in the canonical shape and coerce types.
* Every write path runs through this — that is what keeps keys from drifting.
*/
public static function normalize(PricingRuleType|string $type, array $input): array
{
$kind = self::kind($type);
$out = [];
switch ($kind) {
case self::KIND_RANGE:
foreach (['min', 'max'] as $k) {
if (isset($input[$k]) && $input[$k] !== '' && is_numeric($input[$k])) {
$out[$k] = (int) $input[$k];
}
}
break;
case self::KIND_LIST:
$values = $input['values'] ?? [];
$values = is_array($values) ? $values : [$values];
$values = array_values(array_filter($values, fn ($v) => $v !== '' && $v !== null));
if ($values) {
$out['values'] = $values;
}
break;
case self::KIND_TIMING:
foreach (['before_date', 'after_date'] as $k) {
if (! empty($input[$k])) {
$out[$k] = (string) $input[$k];
}
}
if (isset($input['days_before_start']) && is_numeric($input['days_before_start'])) {
$out['days_before_start'] = (int) $input['days_before_start'];
}
break;
case self::KIND_MONTHS:
$months = $input['months'] ?? [];
$months = array_values(array_filter(
array_map('intval', is_array($months) ? $months : [$months]),
fn ($m) => $m >= 1 && $m <= 12
));
if ($months) {
$out['months'] = $months;
}
break;
case self::KIND_SCHED:
$days = $input['days'] ?? [];
$days = array_values(array_intersect(
array_map(fn ($d) => strtolower((string) $d), is_array($days) ? $days : [$days]),
self::WEEKDAYS
));
if ($days) {
$out['days'] = $days;
}
foreach (['after_hour', 'before_hour'] as $k) {
if (isset($input[$k]) && $input[$k] !== '' && is_numeric($input[$k])) {
$out[$k] = max(0, min(23, (int) $input[$k]));
}
}
break;
}
return $out;
}
/**
* Validation rules for the builder, keyed by `conditions.*`.
*/
public static function validationRules(PricingRuleType|string $type): array
{
return match (self::kind($type)) {
self::KIND_RANGE => [
'conditions.min' => 'nullable|integer|min:0|max:200',
'conditions.max' => 'nullable|integer|min:0|max:200|gte:conditions.min',
],
self::KIND_LIST => [
'conditions.values' => 'required|array|min:1',
'conditions.values.*' => 'required|string|max:50',
],
self::KIND_TIMING => [
'conditions.before_date' => 'nullable|date',
'conditions.after_date' => 'nullable|date',
'conditions.days_before_start' => 'nullable|integer|min:1|max:365',
],
self::KIND_MONTHS => [
'conditions.months' => 'required|array|min:1',
'conditions.months.*' => 'required|integer|min:1|max:12',
],
self::KIND_SCHED => [
'conditions.days' => 'nullable|array',
'conditions.days.*' => 'string|in:' . implode(',', self::WEEKDAYS),
'conditions.after_hour' => 'nullable|integer|min:0|max:23',
'conditions.before_hour' => 'nullable|integer|min:0|max:23',
],
default => [],
};
}
/**
* A rule must actually constrain something, or it is an academy-wide discount
* wearing a specific name. Range and list kinds are required to be non-empty.
*/
public static function isMeaningful(PricingRuleType|string $type, array $conditions): bool
{
return match (self::kind($type)) {
self::KIND_NONE => true,
default => ! empty($conditions),
};
}
/**
* The Arabic fragment for the sentence builder, e.g. "للأعمار من ٦ إلى ١٠ سنة".
*/
public static function describe(PricingRuleType|string $type, array $c): string
{
$key = $type instanceof PricingRuleType ? $type->value : $type;
$unit = self::unit($key);
$range = function (string $noun) use ($c, $unit) {
$min = $c['min'] ?? null;
$max = $c['max'] ?? null;
if ($min !== null && $max !== null) {
return "{$noun} من {$min} إلى {$max}" . ($unit ? " {$unit}" : '');
}
if ($min !== null) {
return "{$noun} {$min}" . ($unit ? " {$unit}" : '') . ' فأكثر';
}
if ($max !== null) {
return "{$noun} حتى {$max}" . ($unit ? " {$unit}" : '');
}
return $noun;
};
return match ($key) {
'age' => $range('للأعمار'),
'membership_duration' => $range('لمدة عضوية'),
'family_size' => $range('لأسرة عدد أبنائها'),
'sibling_order' => $range('للابن رقم'),
'enrollment_volume' => $range('لعدد برامج'),
'loyalty' => $range('لعضوية'),
'classification' => 'لتصنيف: ' . implode('، ', array_map(
fn ($v) => self::classificationLabel($v),
$c['values'] ?? []
)),
'gender' => 'للـ' . implode('، ', array_map(
fn ($v) => $v === 'male' ? 'ذكور' : 'إناث',
$c['values'] ?? []
)),
'branch' => 'لفروع محددة',
'enrollment_timing' => isset($c['days_before_start'])
? "للتسجيل قبل البداية بـ {$c['days_before_start']} يوم"
: 'في فترة تسجيل محددة',
'seasonal' => 'في شهور: ' . implode('، ', array_map(
fn ($m) => self::monthLabel((int) $m),
$c['months'] ?? []
)),
'day_time' => self::describeSchedule($c),
'custom' => 'يدوي — يُطبق بالاختيار فقط',
default => '',
};
}
private static function describeSchedule(array $c): string
{
$parts = [];
if (! empty($c['days'])) {
$parts[] = 'أيام ' . implode('، ', array_map(fn ($d) => self::dayLabel($d), $c['days']));
}
if (isset($c['after_hour']) && isset($c['before_hour'])) {
$parts[] = "من الساعة {$c['after_hour']} إلى {$c['before_hour']}";
} elseif (isset($c['after_hour'])) {
$parts[] = "بعد الساعة {$c['after_hour']}";
} elseif (isset($c['before_hour'])) {
$parts[] = "قبل الساعة {$c['before_hour']}";
}
return $parts ? implode(' ', $parts) : 'في مواعيد محددة';
}
public static function classificationLabel(string $v): string
{
return match ($v) {
'regular' => 'عادي',
'vip' => 'مميز',
'scholarship' => 'منحة',
'staff_child' => 'ابن عامل',
'trial' => 'تجريبي',
default => $v,
};
}
public static function dayLabel(string $d): string
{
return match (strtolower($d)) {
'saturday' => 'السبت',
'sunday' => 'الأحد',
'monday' => 'الإثنين',
'tuesday' => 'الثلاثاء',
'wednesday' => 'الأربعاء',
'thursday' => 'الخميس',
'friday' => 'الجمعة',
default => $d,
};
}
public static function monthLabel(int $m): string
{
return [1 => 'يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو',
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'][$m] ?? (string) $m;
}
}
<?php
namespace App\Domain\Pricing\Support;
/**
* The twelve discounts a sports academy actually gives.
*
* A recipe pre-fills the builder — rule type, conditions, adjustment, stacking,
* pin — so authoring a sibling discount is "change two numbers", not five steps
* of database columns. Recipes are templates, not rows: once created the rule is
* an ordinary pricing_rule that carries `recipe_key` for reporting.
*/
final class DiscountRecipe
{
public const RECIPES = [
'sibling' => [
'icon' => '👨‍👩‍👧',
'name_ar' => 'خصم الإخوة',
'hint_ar' => 'الابن الثاني فأكثر',
'rule_type' => 'sibling_order',
'adjustment_type' => 'percentage_discount',
'percent' => 20,
'conditions' => ['min' => 2],
'is_stackable' => false,
'is_pinned' => true,
],
'early_bird' => [
'icon' => '🐦',
'name_ar' => 'الحجز المبكر',
'hint_ar' => 'قبل بداية الموسم',
'rule_type' => 'enrollment_timing',
'adjustment_type' => 'percentage_discount',
'percent' => 10,
'conditions' => ['days_before_start' => 30],
'is_stackable' => false,
'is_pinned' => true,
],
'scholarship' => [
'icon' => '🎓',
'name_ar' => 'منحة دراسية',
'hint_ar' => 'حسب التصنيف',
'rule_type' => 'classification',
'adjustment_type' => 'percentage_discount',
'percent' => 50,
'conditions' => ['values' => ['scholarship']],
'is_stackable' => false,
'is_pinned' => false,
],
'extra_program' => [
'icon' => '🏃',
'name_ar' => 'برنامج إضافي',
'hint_ar' => 'من البرنامج الثاني',
'rule_type' => 'enrollment_volume',
'adjustment_type' => 'percentage_discount',
'percent' => 10,
'conditions' => ['min' => 2],
'is_stackable' => false,
'is_pinned' => true,
],
'morning_slot' => [
'icon' => '🌅',
'name_ar' => 'مواعيد الصباح',
'hint_ar' => 'قبل الساعة ٣',
'rule_type' => 'day_time',
'adjustment_type' => 'percentage_discount',
'percent' => 15,
'conditions' => ['before_hour' => 15],
'is_stackable' => false,
'is_pinned' => false,
],
'annual' => [
'icon' => '📅',
'name_ar' => 'اشتراك سنوي',
'hint_ar' => 'دفعة واحدة',
'rule_type' => 'membership_duration',
'adjustment_type' => 'percentage_discount',
'percent' => 15,
'conditions' => ['min' => 12],
'is_stackable' => false,
'is_pinned' => false,
],
'staff_child' => [
'icon' => '👔',
'name_ar' => 'أبناء العاملين',
'hint_ar' => 'يُجمع مع غيره',
'rule_type' => 'classification',
'adjustment_type' => 'percentage_discount',
'percent' => 50,
'conditions' => ['values' => ['staff_child']],
'is_stackable' => true,
'is_pinned' => false,
],
'loyalty' => [
'icon' => '⏳',
'name_ar' => 'عضو قديم',
'hint_ar' => 'بعد ١٢ شهر',
'rule_type' => 'loyalty',
'adjustment_type' => 'percentage_discount',
'percent' => 10,
'conditions' => ['min' => 12],
'is_stackable' => false,
'is_pinned' => false,
],
'season_end' => [
'icon' => '🏁',
'name_ar' => 'نهاية الموسم',
'hint_ar' => 'شهور محددة',
'rule_type' => 'seasonal',
'adjustment_type' => 'percentage_discount',
'percent' => 25,
'conditions' => ['months' => [6, 7, 8]],
'is_stackable' => false,
'is_pinned' => false,
],
'birthday' => [
'icon' => '🎂',
'name_ar' => 'عرض عيد الميلاد',
'hint_ar' => 'شهر الميلاد',
'rule_type' => 'seasonal',
'adjustment_type' => 'percentage_discount',
'percent' => 10,
'conditions' => ['months' => [1]],
'is_stackable' => true,
'is_pinned' => false,
],
'juniors' => [
'icon' => '🧒',
'name_ar' => 'خصم البراعم',
'hint_ar' => 'أقل من ٧ سنوات',
'rule_type' => 'age',
'adjustment_type' => 'percentage_discount',
'percent' => 10,
'conditions' => ['max' => 6],
'is_stackable' => false,
'is_pinned' => false,
],
'manual' => [
'icon' => '✋',
'name_ar' => 'خصم استثنائي',
'hint_ar' => 'يدوي باعتماد',
'rule_type' => 'custom',
'adjustment_type' => 'percentage_discount',
'percent' => 0,
'conditions' => [],
'is_stackable' => true,
'is_pinned' => true,
'is_manual' => true,
'requires_approval' => true,
],
];
public static function all(): array
{
return self::RECIPES;
}
public static function find(string $key): ?array
{
return self::RECIPES[$key] ?? null;
}
/** Recipe defaults as builder field values (percent stays human here). */
public static function prefill(string $key): array
{
$r = self::find($key);
if (! $r) {
return [];
}
return [
'recipe_key' => $key,
'name_ar' => $r['name_ar'],
'rule_type' => $r['rule_type'],
'adjustment_type' => $r['adjustment_type'],
'percent' => $r['percent'],
'conditions' => $r['conditions'],
'is_stackable' => $r['is_stackable'],
'is_pinned' => $r['is_pinned'],
'pin_icon' => $r['icon'],
'is_manual' => $r['is_manual'] ?? false,
'requires_approval' => $r['requires_approval'] ?? false,
];
}
}
<?php
namespace App\Domain\Pricing\Support;
/**
* Percentages are stored as basis points (10% = 1000) so 12.5% is expressible.
*
* Humans never type basis points and never read them. Every conversion happens
* here — the old bug was two authoring screens storing a plain percent into a
* column the engine divided by 10000, making every discount 100x too small.
*/
final class Percent
{
public const SCALE = 100;
/** Human percent ("20", "12.5") -> stored basis points (2000, 1250). */
public static function toBasisPoints(float|int|string $percent): int
{
return (int) round((float) $percent * self::SCALE);
}
/** Stored basis points (2000) -> human percent (20.0). */
public static function fromBasisPoints(int $basisPoints): float
{
return round($basisPoints / self::SCALE, 2);
}
/** Display string, trimming a trailing ".0" — "20%" not "20.0%". */
public static function label(int $basisPoints): string
{
$v = self::fromBasisPoints($basisPoints);
return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.') . '%';
}
}
...@@ -50,6 +50,14 @@ public function check(): void ...@@ -50,6 +50,14 @@ public function check(): void
'name' => $promotion->name_ar ?? $promotion->name, 'name' => $promotion->name_ar ?? $promotion->name,
'type' => $promotion->adjustment_type, 'type' => $promotion->adjustment_type,
'value' => $promotion->adjustment_value, 'value' => $promotion->adjustment_value,
// Percentages are basis points in the column, so the raw number is
// not displayable. The view renders this instead.
'value_label' => in_array($promotion->adjustment_type, [
\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount,
\App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease,
], true)
? \App\Domain\Pricing\Support\Percent::label($promotion->adjustment_value)
: format_money($promotion->adjustment_value),
'remaining' => $promotion->max_uses ? ($promotion->max_uses - $promotion->times_used) : null, 'remaining' => $promotion->max_uses ? ($promotion->max_uses - $promotion->times_used) : null,
'expires' => $promotion->end_date, 'expires' => $promotion->end_date,
]; ];
......
...@@ -2,230 +2,344 @@ ...@@ -2,230 +2,344 @@
namespace App\Livewire\Pricing; namespace App\Livewire\Pricing;
use App\Domain\Identity\Models\Branch;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Enums\AdjustmentType; use App\Domain\Pricing\Enums\AdjustmentType;
use App\Domain\Pricing\Enums\PricingRuleType; use App\Domain\Pricing\Enums\PricingRuleType;
use App\Domain\Pricing\Models\PricingRule; use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Pricing\Support\ConditionSchema;
use App\Domain\Pricing\Support\DiscountRecipe;
use App\Domain\Pricing\Support\Percent;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingProgram;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
/**
* Discount builder — recipe gallery, then a sentence you can read out loud.
*
* Replaces the five-step column editor. The author never meets rule_type,
* basis points, morph classes or a conditions JSON blob.
*/
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('إنشاء قاعدة تسعير')] #[Title('إنشاء خصم')]
class CreatePricingRuleWizard extends Component class CreatePricingRuleWizard extends Component
{ {
public int $currentStep = 1; use UsesBranchScope;
public int $totalSteps = 5;
public bool $completed = false;
// Step 1: Rule Type public string $stage = 'recipes'; // recipes | build | done
public string $ruleType = '';
// Step 2: Conditions (dynamic based on rule_type) public ?string $recipeKey = null;
public ?int $minAge = null; public ?int $createdRuleId = null;
public ?int $maxAge = null;
public ?int $minMonths = null;
public ?int $minChildren = null;
public ?int $orderNumber = null;
public string $targetGender = '';
public ?int $targetBranchId = null;
public string $targetClassification = '';
public string $genericConditions = '';
// Step 3: Adjustment
public string $adjustmentType = '';
public ?int $adjustmentValue = null;
public ?int $maxDiscountPercent = null;
// Step 4: Scope & Priority // ─── The sentence ─────────────────────────────────────────────
public string $nameAr = ''; public string $nameAr = '';
public string $name = ''; public string $ruleType = '';
public string $adjustmentType = 'percentage_discount';
public string $amount = ''; // percent, or pounds for fixed types
public array $conditions = [];
public array $branchIds = [];
public ?string $targetType = null; public ?string $targetType = null;
public ?int $targetId = null; public ?int $targetId = null;
public ?int $branchId = null;
public int $priority = 10; // ─── Behaviour ────────────────────────────────────────────────
public bool $isStackable = true; public bool $isStackable = false; // best-of-one is the normal case
public bool $isPinned = false;
public ?string $pinIcon = null;
public int $displayOrder = 0;
public bool $isManual = false;
public bool $requiresApproval = false;
public ?int $minRoleLevel = null;
public string $effectiveFrom = ''; public string $effectiveFrom = '';
public ?string $effectiveTo = null; public ?string $effectiveTo = null;
public bool $isActive = true; public bool $isActive = true;
public ?int $usageLimit = null; public ?int $usageLimit = null;
public ?int $maxDiscountPercent = null;
// ─── Simulator ────────────────────────────────────────────────
public ?int $simParticipantId = null;
public ?int $simProgramId = null;
public array $simulation = [];
public array $audience = [];
public function mount(): void public function mount(): void
{ {
$this->authorize('pricing.create'); $this->authorize('pricing.create');
$this->effectiveFrom = now()->toDateString(); $this->effectiveFrom = now()->toDateString();
// Default to the branch you are standing in — academy-wide should be
// a deliberate act, not what you get by not thinking about it.
$this->branchIds = array_values(array_filter([$this->getActiveBranchId()]));
} }
public function getStepLabels(): array // ─── Stage 1: recipes ─────────────────────────────────────────
public function chooseRecipe(string $key): void
{ {
return [ $fill = DiscountRecipe::prefill($key);
1 => 'نوع القاعدة', if (! $fill) {
2 => 'الشروط', return;
3 => 'التعديل', }
4 => 'النطاق والأولوية',
5 => 'مراجعة', $this->recipeKey = $fill['recipe_key'];
]; $this->nameAr = $fill['name_ar'];
$this->ruleType = $fill['rule_type'];
$this->adjustmentType = $fill['adjustment_type'];
$this->amount = (string) $fill['percent'];
$this->conditions = $fill['conditions'];
$this->isStackable = $fill['is_stackable'];
$this->isPinned = $fill['is_pinned'];
$this->pinIcon = $fill['pin_icon'];
$this->isManual = $fill['is_manual'];
$this->requiresApproval = $fill['requires_approval'];
$this->stage = 'build';
$this->refreshPreview();
} }
public function updatedRuleType(): void public function startBlank(): void
{ {
$this->minAge = null; $this->recipeKey = null;
$this->maxAge = null; $this->nameAr = '';
$this->minMonths = null; $this->ruleType = 'age';
$this->minChildren = null; $this->conditions = [];
$this->orderNumber = null; $this->amount = '';
$this->targetGender = ''; $this->stage = 'build';
$this->targetBranchId = null;
$this->targetClassification = '';
$this->genericConditions = '';
} }
public function nextStep(): void public function backToRecipes(): void
{ {
$this->validate($this->rulesForStep($this->currentStep)); $this->stage = 'recipes';
$this->simulation = [];
$this->audience = [];
}
// ─── Live preview ─────────────────────────────────────────────
if ($this->currentStep < $this->totalSteps) { public function updated(string $field): void
$this->currentStep++; {
if (str_starts_with($field, 'conditions') || in_array($field, ['ruleType', 'amount', 'adjustmentType'], true)) {
$this->refreshPreview();
}
if (in_array($field, ['simParticipantId', 'simProgramId'], true)) {
$this->refreshPreview();
} }
} }
public function previousStep(): void public function updatedRuleType(): void
{ {
if ($this->currentStep > 1) { $this->conditions = [];
$this->currentStep--;
}
} }
public function goToStep(int $step): void /** The whole rule as one readable Arabic sentence. */
public function getSentenceProperty(): string
{ {
if ($step < $this->currentStep) { $value = $this->amount !== '' ? $this->amount : '—';
$this->currentStep = $step; $verb = match ($this->adjustmentType) {
} 'percentage_increase' => "زِد السعر {$value}%",
'fixed_discount' => "امنح خصم {$value} ج.م",
'fixed_price' => "اجعل السعر {$value} ج.م",
default => "امنح خصم {$value}%",
};
$target = $this->targetId ? 'على برنامج محدد' : 'على كل البرامج';
$who = $this->ruleType ? ConditionSchema::describe($this->ruleType, $this->conditions) : '';
$where = $this->branchIds
? 'في ' . Branch::whereIn('id', $this->branchIds)->pluck('name_ar')->implode('، ')
: 'في كل الفروع';
$when = 'من ' . ($this->effectiveFrom ?: '—')
. ($this->effectiveTo ? " إلى {$this->effectiveTo}" : ' بدون نهاية');
return trim("{$verb} {$target} {$who} {$where} {$when}");
} }
public function confirm(): void /** Run the draft rule against a real participant + program. */
public function refreshPreview(): void
{ {
$this->simulation = [];
$this->audience = [];
if (! $this->ruleType || $this->amount === '') {
return;
}
$draft = $this->draftRule();
$service = app(PricingService::class);
try { try {
DB::transaction(function () { $this->audience = $service->audience($draft, $this->branchIds[0] ?? null);
PricingRule::create([ } catch (\Throwable $e) {
'academy_id' => app('current_academy')->id, $this->audience = [];
'name_ar' => $this->nameAr, }
'name' => $this->name ?: null,
'rule_type' => $this->ruleType,
'adjustment_type' => $this->adjustmentType,
'adjustment_value' => $this->adjustmentValue,
'conditions' => $this->buildConditions(),
'target_type' => $this->targetType ?: null,
'target_id' => $this->targetId ?: null,
'branch_id' => $this->branchId ?: null,
'priority' => $this->priority,
'is_stackable' => $this->isStackable,
'max_discount_percent' => $this->maxDiscountPercent,
'effective_from' => $this->effectiveFrom,
'effective_to' => $this->effectiveTo ?: null,
'is_active' => $this->isActive,
'usage_limit' => $this->usageLimit,
'usage_count' => 0,
'created_by' => auth()->id(),
]);
});
$this->completed = true; if (! $this->simParticipantId || ! $this->simProgramId) {
return;
}
$participant = Participant::with(['person', 'guardians'])->find($this->simParticipantId);
$program = TrainingProgram::find($this->simProgramId);
if (! $participant || ! $program) {
return;
}
try {
$live = $service->calculate($program, $participant, $this->branchIds[0] ?? null);
// What the draft rule alone would do to the current price.
$qualifies = ! empty($draft->conditions)
? $service->wouldApply($draft, $participant)
: true;
$this->simulation = [
'participant' => $participant->full_name,
'program' => $program->name_ar ?? $program->name,
'base' => $live->baseAmount,
'current' => $live->finalAmount,
'applied' => $live->appliedRules,
'qualifies' => $qualifies,
'draft_name' => $this->nameAr ?: 'الخصم الجديد',
];
} catch (DomainException $e) { } catch (DomainException $e) {
session()->flash('error', $e->getMessage()); $this->simulation = ['error' => $e->getMessage()];
} }
} }
private function buildConditions(): array /** An unsaved PricingRule used only for preview — never persisted. */
private function draftRule(): PricingRule
{ {
return match ($this->ruleType) { $rule = new PricingRule([
'age' => ['min_age' => $this->minAge, 'max_age' => $this->maxAge], 'name_ar' => $this->nameAr ?: 'مسودة',
'membership_duration' => ['min_months' => $this->minMonths], 'rule_type' => $this->ruleType,
'family_size' => ['min_children' => $this->minChildren], 'adjustment_type' => $this->adjustmentType,
'sibling_order' => ['order_number' => $this->orderNumber], 'conditions' => ConditionSchema::normalize($this->ruleType, $this->conditions),
'gender' => ['target_gender' => $this->targetGender], 'is_stackable' => $this->isStackable,
'branch' => ['target_branch_id' => $this->targetBranchId], ]);
'classification' => ['target_classification' => $this->targetClassification],
default => json_decode($this->genericConditions ?: '{}', true) ?: [], $rule->adjustment_value = $this->storedAmount();
};
return $rule;
} }
private function rulesForStep(int $step): array private function storedAmount(): int
{ {
return match ($step) { return in_array($this->adjustmentType, ['fixed_discount', 'fixed_price'], true)
1 => [ ? (int) round((float) $this->amount * 100)
'ruleType' => 'required|in:' . implode(',', array_column(PricingRuleType::cases(), 'value')), : Percent::toBasisPoints($this->amount ?: 0);
],
2 => $this->conditionRulesForType(),
3 => [
'adjustmentType' => 'required|in:' . implode(',', array_column(AdjustmentType::cases(), 'value')),
'adjustmentValue' => 'required|integer|min:1',
'maxDiscountPercent' => 'nullable|integer|min:1|max:100',
],
4 => [
'nameAr' => 'required|string|min:3|max:255',
'name' => 'nullable|string|max:255',
'priority' => 'required|integer|min:1|max:100',
'effectiveFrom' => 'required|date',
'effectiveTo' => 'nullable|date|after:effectiveFrom',
'usageLimit' => 'nullable|integer|min:1',
],
default => [],
};
} }
private function conditionRulesForType(): array // ─── Save ─────────────────────────────────────────────────────
public function rules(): array
{ {
return match ($this->ruleType) { return [
'age' => [ 'nameAr' => 'required|string|min:3|max:255',
'minAge' => 'nullable|integer|min:1|max:100', 'ruleType' => 'required|in:' . implode(',', array_column(PricingRuleType::cases(), 'value')),
'maxAge' => 'nullable|integer|min:1|max:100', 'adjustmentType' => 'required|in:' . implode(',', array_column(AdjustmentType::cases(), 'value')),
], 'amount' => 'required|numeric|min:0.01',
'membership_duration' => [ 'branchIds' => 'array',
'minMonths' => 'required|integer|min:1', 'branchIds.*' => 'integer|exists:branches,id',
], 'effectiveFrom' => 'required|date',
'family_size' => [ 'effectiveTo' => 'nullable|date|after:effectiveFrom',
'minChildren' => 'required|integer|min:2', 'usageLimit' => 'nullable|integer|min:1',
], 'maxDiscountPercent' => 'nullable|integer|min:1|max:100',
'sibling_order' => [ 'displayOrder' => 'integer|min:0',
'orderNumber' => 'required|integer|min:1', ] + ConditionSchema::validationRules($this->ruleType ?: 'custom');
],
'gender' => [
'targetGender' => 'required|in:male,female',
],
'branch' => [
'targetBranchId' => 'required|integer|exists:branches,id',
],
'classification' => [
'targetClassification' => 'required|in:regular,vip,scholarship,staff_child,trial',
],
default => [],
};
} }
public function getRuleTypeLabelProperty(): string public function messages(): array
{ {
if (!$this->ruleType) { return [
return ''; 'nameAr.required' => __('اسم الخصم مطلوب'),
} 'nameAr.min' => __('اسم الخصم قصير جداً'),
return PricingRuleType::from($this->ruleType)->label(); 'ruleType.required' => __('نوع الشرط مطلوب'),
'adjustmentType.required' => __('نوع التعديل مطلوب'),
'amount.required' => __('قيمة الخصم مطلوبة'),
'amount.numeric' => __('قيمة الخصم يجب أن تكون رقماً'),
'amount.min' => __('قيمة الخصم يجب أن تكون أكبر من صفر'),
'effectiveFrom.required' => __('تاريخ البداية مطلوب'),
'effectiveTo.after' => __('تاريخ النهاية يجب أن يكون بعد تاريخ البداية'),
'conditions.values.required' => __('يجب اختيار قيمة واحدة على الأقل'),
'conditions.months.required' => __('يجب اختيار شهر واحد على الأقل'),
'conditions.max.gte' => __('الحد الأعلى يجب أن يكون أكبر من الحد الأدنى'),
];
} }
public function getAdjustmentTypeLabelProperty(): string public function save(): void
{ {
if (!$this->adjustmentType) { $this->authorize('pricing.create');
return ''; $this->validate();
$conditions = ConditionSchema::normalize($this->ruleType, $this->conditions);
// A rule with no conditions is an academy-wide discount wearing a
// specific name. Manual rules are the deliberate exception.
if (! $this->isManual && ! ConditionSchema::isMeaningful($this->ruleType, $conditions)) {
$this->addError('conditions', __('يجب تحديد شرط واحد على الأقل — وإلا سيُطبق الخصم على كل المشتركين'));
return;
}
try {
$rule = DB::transaction(function () use ($conditions) {
$rule = PricingRule::create([
'academy_id' => app('current_academy')->id,
'name_ar' => $this->nameAr,
'rule_type' => $this->ruleType,
'adjustment_type' => $this->adjustmentType,
'adjustment_value' => $this->storedAmount(),
'conditions' => $conditions,
'target_type' => $this->targetType ?: null,
'target_id' => $this->targetId ?: null,
// Legacy single-branch column stays null; the pivot owns targeting.
'branch_id' => null,
'priority' => 10,
'is_stackable' => $this->isStackable,
'max_discount_percent' => $this->maxDiscountPercent,
'effective_from' => $this->effectiveFrom,
'effective_to' => $this->effectiveTo ?: null,
'is_active' => $this->isActive,
'usage_limit' => $this->usageLimit,
'usage_count' => 0,
'created_by' => auth()->id(),
'is_pinned' => $this->isPinned,
'pin_icon' => $this->pinIcon,
'display_order' => $this->displayOrder,
'recipe_key' => $this->recipeKey,
'is_manual' => $this->isManual,
'requires_approval' => $this->requiresApproval,
'min_role_level' => $this->minRoleLevel,
]);
if ($this->branchIds) {
$rule->branches()->sync($this->branchIds);
}
return $rule;
});
$this->createdRuleId = $rule->id;
$this->stage = 'done';
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} }
return AdjustmentType::from($this->adjustmentType)->label();
} }
public function render() public function render()
{ {
return view('livewire.pricing.create-pricing-rule-wizard', [ return view('livewire.pricing.create-pricing-rule-wizard', [
'ruleTypes' => PricingRuleType::cases(), 'recipes' => DiscountRecipe::all(),
'adjustmentTypes' => AdjustmentType::cases(), 'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']),
'branches' => \App\Domain\Identity\Models\Branch::orderBy('name_ar')->get(['id', 'name_ar', 'name']), 'programs' => TrainingProgram::orderBy('name_ar')->get(['id', 'name_ar']),
'schemaKind' => $this->ruleType ? ConditionSchema::kind($this->ruleType) : null,
'schemaUnit' => $this->ruleType ? ConditionSchema::unit($this->ruleType) : null,
'ruleTypes' => PricingRuleType::cases(),
]); ]);
} }
} }
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
use App\Domain\Pricing\Enums\AdjustmentType; use App\Domain\Pricing\Enums\AdjustmentType;
use App\Domain\Pricing\Enums\PricingRuleType; use App\Domain\Pricing\Enums\PricingRuleType;
use App\Domain\Pricing\Models\PricingRule; use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Pricing\Support\ConditionSchema;
use App\Domain\Pricing\Support\Percent;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
...@@ -68,7 +70,9 @@ private function formatAdjustmentValueForDisplay(PricingRule $rule): string ...@@ -68,7 +70,9 @@ private function formatAdjustmentValueForDisplay(PricingRule $rule): string
if (in_array($rule->adjustment_type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) { if (in_array($rule->adjustment_type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) {
return (string) ($rule->adjustment_value / 100); return (string) ($rule->adjustment_value / 100);
} }
return (string) $rule->adjustment_value;
// Percentages live in the column as basis points; humans read percent.
return (string) Percent::fromBasisPoints((int) $rule->adjustment_value);
} }
public function rules(): array public function rules(): array
...@@ -121,13 +125,23 @@ public function save(): void ...@@ -121,13 +125,23 @@ public function save(): void
$adjustmentValue = $this->computeAdjustmentValueForStorage(); $adjustmentValue = $this->computeAdjustmentValueForStorage();
// Every write goes through the schema so condition keys cannot drift
// away from what the engine reads.
$conditions = ConditionSchema::normalize($this->rule_type, $this->conditions ?? []);
if (! ConditionSchema::isMeaningful($this->rule_type, $conditions)) {
$this->addError('conditions', __('يجب تحديد شرط واحد على الأقل — وإلا سيُطبق الخصم على الجميع'));
return;
}
$data = [ $data = [
'name_ar' => $this->name_ar, 'name_ar' => $this->name_ar,
'name' => $this->name ?: null, 'name' => $this->name ?: null,
'rule_type' => $this->rule_type, 'rule_type' => $this->rule_type,
'adjustment_type' => $this->adjustment_type, 'adjustment_type' => $this->adjustment_type,
'adjustment_value' => $adjustmentValue, 'adjustment_value' => $adjustmentValue,
'conditions' => !empty($this->conditions) ? $this->conditions : null, 'conditions' => $conditions,
'target_type' => $this->target_type ?: null, 'target_type' => $this->target_type ?: null,
'target_id' => $this->target_id ? (int) $this->target_id : null, 'target_id' => $this->target_id ? (int) $this->target_id : null,
'branch_id' => $this->branch_id ? (int) $this->branch_id : null, 'branch_id' => $this->branch_id ? (int) $this->branch_id : null,
...@@ -159,7 +173,10 @@ private function computeAdjustmentValueForStorage(): int ...@@ -159,7 +173,10 @@ private function computeAdjustmentValueForStorage(): int
if (in_array($type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) { if (in_array($type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) {
return (int) round((float) $this->adjustment_value * 100); return (int) round((float) $this->adjustment_value * 100);
} }
return (int) $this->adjustment_value;
// The engine divides by 10000. Storing a bare "20" made every
// percentage discount 100x too small.
return Percent::toBasisPoints($this->adjustment_value);
} }
public function render() public function render()
......
...@@ -70,7 +70,8 @@ private function formatAdjustmentForDisplay(Promotion $promotion): string ...@@ -70,7 +70,8 @@ private function formatAdjustmentForDisplay(Promotion $promotion): string
if (in_array($promotion->adjustment_type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) { if (in_array($promotion->adjustment_type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) {
return (string) ($promotion->adjustment_value / 100); return (string) ($promotion->adjustment_value / 100);
} }
return (string) $promotion->adjustment_value;
return (string) \App\Domain\Pricing\Support\Percent::fromBasisPoints((int) $promotion->adjustment_value);
} }
public function rules(): array public function rules(): array
...@@ -157,7 +158,9 @@ private function computeAdjustmentValueForStorage(): int ...@@ -157,7 +158,9 @@ private function computeAdjustmentValueForStorage(): int
if (in_array($type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) { if (in_array($type, [AdjustmentType::FixedDiscount, AdjustmentType::FixedPrice])) {
return (int) round((float) $this->adjustment_value * 100); return (int) round((float) $this->adjustment_value * 100);
} }
return (int) $this->adjustment_value;
// Basis points — the engine divides by 10000.
return \App\Domain\Pricing\Support\Percent::toBasisPoints($this->adjustment_value);
} }
public function render() public function render()
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
use App\Domain\Financial\Services\InvoiceService; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService; use App\Domain\Financial\Services\PaymentService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Concerns\ManagesDiscounts;
use App\Domain\Pricing\Services\PricingService; 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;
...@@ -23,10 +24,13 @@ ...@@ -23,10 +24,13 @@
#[Title('تحصيل مدفوعات')] #[Title('تحصيل مدفوعات')]
class CollectPaymentWizard extends Component class CollectPaymentWizard extends Component
{ {
use UsesBranchScope; use UsesBranchScope, ManagesDiscounts;
public ?int $branchId = null; public ?int $branchId = null;
/** Enrollment whose renewal price is currently being composed. */
public ?int $pricing_enrollment_id = null;
public int $currentStep = 1; public int $currentStep = 1;
public int $totalSteps = 5; public int $totalSteps = 5;
...@@ -150,6 +154,30 @@ public function selectParticipant(int $id, string $name): void ...@@ -150,6 +154,30 @@ public function selectParticipant(int $id, string $name): void
$this->generatePendingRenewals(); $this->generatePendingRenewals();
} }
/**
* Show what this participant may have before the invoice is written, so the
* receptionist can change it rather than discovering it on the receipt.
*/
public function prepareRenewalPricing(int $enrollmentId): void
{
$enrollment = Enrollment::with(['program', 'participant.person', 'group'])->find($enrollmentId);
if (!$enrollment || $enrollment->participant_id !== $this->selected_participant_id) {
return;
}
$this->pricing_enrollment_id = $enrollmentId;
$this->selectedDiscountIds = [];
$this->declinedDiscountIds = [];
$this->clearManualDiscount();
$this->loadDiscounts(
priceable: $enrollment->program,
participant: $enrollment->participant,
branchId: $enrollment->group?->branch_id ?? $enrollment->participant->branch_id,
extraContext: ['program_starts_on' => $enrollment->program->program_start_date ?? null],
);
}
public function generateRenewalForEnrollment(int $enrollmentId): void public function generateRenewalForEnrollment(int $enrollmentId): void
{ {
$enrollment = Enrollment::with(['program', 'participant.person', 'group'])->find($enrollmentId); $enrollment = Enrollment::with(['program', 'participant.person', 'group'])->find($enrollmentId);
...@@ -157,6 +185,11 @@ public function generateRenewalForEnrollment(int $enrollmentId): void ...@@ -157,6 +185,11 @@ public function generateRenewalForEnrollment(int $enrollmentId): void
return; return;
} }
// A manual discount must clear the actor's ceiling before it reaches an invoice.
if ($this->manualDiscountAmount !== '' && !$this->validateManualDiscount()) {
return;
}
try { try {
$invoiceService = app(InvoiceService::class); $invoiceService = app(InvoiceService::class);
$pricingService = app(PricingService::class); $pricingService = app(PricingService::class);
...@@ -171,7 +204,19 @@ public function generateRenewalForEnrollment(int $enrollmentId): void ...@@ -171,7 +204,19 @@ public function generateRenewalForEnrollment(int $enrollmentId): void
branchId: $group?->branch_id ?? $participant->branch_id, branchId: $group?->branch_id ?? $participant->branch_id,
); );
if ($priceResult->finalAmount <= 0) { // The picker is authoritative once the operator has touched it: the
// engine proposes, the receptionist disposes, the invoice records both.
if ($this->pricing_enrollment_id === $enrollmentId && $this->discountCandidates) {
$chosenDiscount = $this->selectedDiscountTotal();
$finalAmount = max(0, $priceResult->baseAmount - $chosenDiscount);
$discountLines = $this->discountSnapshot();
} else {
$chosenDiscount = $priceResult->totalDiscount;
$finalAmount = $priceResult->finalAmount;
$discountLines = $priceResult->appliedRules;
}
if ($finalAmount <= 0) {
$this->advanceEnrollmentBillingDate($enrollment); $this->advanceEnrollmentBillingDate($enrollment);
session()->flash('info', __('الاشتراك مجاني - تم التجديد بدون فاتورة')); session()->flash('info', __('الاشتراك مجاني - تم التجديد بدون فاتورة'));
return; return;
...@@ -182,9 +227,9 @@ public function generateRenewalForEnrollment(int $enrollmentId): void ...@@ -182,9 +227,9 @@ public function generateRenewalForEnrollment(int $enrollmentId): void
'branch_id' => $group?->branch_id ?? $participant->branch_id, 'branch_id' => $group?->branch_id ?? $participant->branch_id,
'billable_type' => $participant->getMorphClass(), 'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id, 'billable_id' => $participant->id,
'total_amount' => $priceResult->finalAmount, 'total_amount' => $finalAmount,
'subtotal_amount' => $priceResult->baseAmount, 'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount, 'discount_amount' => $chosenDiscount,
'tax_amount' => 0, 'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(), 'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name, 'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
...@@ -194,13 +239,19 @@ public function generateRenewalForEnrollment(int $enrollmentId): void ...@@ -194,13 +239,19 @@ public function generateRenewalForEnrollment(int $enrollmentId): void
'description' => "تجديد اشتراك: {$program->name_ar}", 'description' => "تجديد اشتراك: {$program->name_ar}",
'quantity' => 1, 'quantity' => 1,
'unit_price' => $priceResult->baseAmount, 'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount, 'discount_amount' => $chosenDiscount,
'tax_amount' => 0, 'tax_amount' => 0,
], ],
], auth()->user()); ], auth()->user());
// Mark invoice as sent so it appears as payable // Freeze the discount names on the invoice so the receipt stays
$invoice->update(['status' => InvoiceStatus::Sent]); // readable after the rules change.
$invoice->update([
'status' => InvoiceStatus::Sent,
'metadata' => array_merge($invoice->metadata ?? [], [
'applied_discounts' => $discountLines,
]),
]);
$this->advanceEnrollmentBillingDate($enrollment); $this->advanceEnrollmentBillingDate($enrollment);
...@@ -552,6 +603,9 @@ public function render() ...@@ -552,6 +603,9 @@ public function render()
'upcomingPayments' => $upcomingPayments, 'upcomingPayments' => $upcomingPayments,
'selectedInvoice' => $selectedInvoice, 'selectedInvoice' => $selectedInvoice,
'isSuperAdmin' => auth()->user()?->is_super_admin ?? false, 'isSuperAdmin' => auth()->user()?->is_super_admin ?? false,
// The picker must say which branch it is showing — a receptionist who
// cannot see why a discount is missing will phone the office.
'branchName' => \App\Domain\Identity\Models\Branch::where('id', $this->branchId)->value('name_ar'),
]); ]);
} }
} }
...@@ -309,7 +309,7 @@ public function nextStep(): void ...@@ -309,7 +309,7 @@ public function nextStep(): void
// Price guard on step 4 — block if no base price for selected program (skip for free players) // Price guard on step 4 — block if no base price for selected program (skip for free players)
if ($this->currentStep === 4 && $this->selected_program_id && !$this->is_free) { if ($this->currentStep === 4 && $this->selected_program_id && !$this->is_free) {
$program = TrainingProgram::find($this->selected_program_id); $program = TrainingProgram::find($this->selected_program_id);
if ($program && $this->resolveProgramFee($program) === 0) { if ($program && $this->resolveProgramBasePrice($program) === 0) {
$this->addError('selected_program_id', 'لا يوجد سعر محدد لهذا البرنامج — تواصل مع المسؤول'); $this->addError('selected_program_id', 'لا يوجد سعر محدد لهذا البرنامج — تواصل مع المسؤول');
return; return;
} }
...@@ -1283,7 +1283,80 @@ private function buildGatewayData(string $method, string $ref, string $cheque, s ...@@ -1283,7 +1283,80 @@ private function buildGatewayData(string $method, string $ref, string $cheque, s
return $data; return $data;
} }
/**
* The price actually charged — base price WITH pricing rules applied.
*
* This flow used to read BasePrice directly, so no sibling discount, early
* bird, coupon or promotion could ever apply at new registration. The
* participant row does not exist yet, so the engine is given a provisional
* context built from the form.
*/
private function resolveProgramFee(TrainingProgram $program): int private function resolveProgramFee(TrainingProgram $program): int
{
try {
return app(\App\Domain\Pricing\Services\PricingService::class)->calculate(
priceable: $program,
participant: null,
branchId: $this->branchId,
date: null,
contextOverride: $this->provisionalContext(),
)->finalAmount;
} catch (\App\Domain\Shared\Exceptions\DomainException $e) {
// No active base price — the step-4 guard reports this properly.
return 0;
}
}
/**
* Participant context derived from the form, for pricing before the row exists.
*/
private function provisionalContext(): array
{
$age = $this->participant_date_of_birth
? (int) \Carbon\Carbon::parse($this->participant_date_of_birth)->diffInYears(now())
: null;
[$familySize, $siblingOrder] = $this->resolveSiblingPosition();
return [
'age' => $age,
'gender' => $this->participant_gender ?: null,
'classification' => 'regular',
'membership_type' => $this->membership_type,
'membership_duration_months' => 0,
'family_size' => $familySize,
'sibling_order' => $siblingOrder,
'enrollment_count' => 0,
'branch_id' => $this->branchId,
];
}
/**
* Siblings are found through the guardian's phone — the only identity we
* have before the participant is saved.
*
* @return array{0:int,1:int} [family size, this child's order]
*/
private function resolveSiblingPosition(): array
{
if (! $this->guardian_phone) {
return [1, 1];
}
$guardian = Guardian::whereHas('person', fn ($q) => $q->where('phone', $this->guardian_phone))->first();
if (! $guardian) {
return [1, 1];
}
// The child being registered is the next one in the family.
$existing = $guardian->participants()->count();
return [$existing + 1, $existing + 1];
}
/** Base price only — used by the step-4 guard, which must not see discounts. */
private function resolveProgramBasePrice(TrainingProgram $program): int
{ {
$query = BasePrice::where('priceable_type', TrainingProgram::class) $query = BasePrice::where('priceable_type', TrainingProgram::class)
->where('priceable_id', $program->id) ->where('priceable_id', $program->id)
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Pricing & discount overhaul.
*
* 1. pricing_rule_branches pivot — one rule, many branches (no more per-branch duplicates)
* 2. Picker columns (pinned quick buttons, display order, recipe provenance)
* 3. Governed manual discount columns (approval + role ceiling)
* 4. is_stackable default flips to FALSE (best-of-one is the normal case)
* 5. BACKFILL: percentage adjustment_value -> basis points (was 100x too small)
* 6. BACKFILL: conditions -> canonical vocabulary (engine could never read the old keys)
*/
return new class extends Migration
{
/** Canonical condition keys the engine reads. */
private const RANGE_MIN = ['min_age', 'min_months', 'min_children', 'min_family_size',
'order_number', 'sibling_order', 'min_enrollments', 'min_points', 'min'];
private const RANGE_MAX = ['max_age', 'max_months', 'max_family_size', 'max'];
private const LIST_KEYS = ['target_gender', 'gender', 'target_classification', 'classifications',
'target_branch_id', 'branch_ids', 'values', 'in'];
public function up(): void
{
// ─── 1. Branch pivot ──────────────────────────────────────────
Schema::create('pricing_rule_branches', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('pricing_rule_id')->constrained('pricing_rules')->cascadeOnDelete();
$table->foreignId('branch_id')->constrained('branches')->cascadeOnDelete();
$table->timestamps();
$table->unique(['pricing_rule_id', 'branch_id'], 'pricing_rule_branches_unique');
$table->index(['academy_id', 'branch_id']);
});
// ─── 2 & 3. New columns ───────────────────────────────────────
Schema::table('pricing_rules', function (Blueprint $table) {
$table->boolean('is_pinned')->default(false);
$table->string('pin_icon', 16)->nullable();
$table->integer('display_order')->default(0);
$table->string('recipe_key', 40)->nullable();
$table->boolean('is_manual')->default(false);
$table->boolean('requires_approval')->default(false);
$table->integer('min_role_level')->nullable();
});
DB::statement('CREATE INDEX pricing_rules_picker_idx ON pricing_rules (academy_id, is_active, is_pinned, display_order)');
// ─── 4. Stackable default flips ───────────────────────────────
DB::statement('ALTER TABLE pricing_rules ALTER COLUMN is_stackable SET DEFAULT false');
// Existing rules have never applied correctly (see backfills below), so
// re-baseline them on the new default rather than leaving a mixed estate.
DB::table('pricing_rules')->update(['is_stackable' => false]);
// ─── 5. Percentage -> basis points ────────────────────────────
// Engine divides by 10000; both authoring screens stored a plain percent.
// Guard: only values that look like a plain percent (<= 100) are scaled.
foreach (['pricing_rules', 'promotions'] as $table) {
DB::table($table)
->whereIn('adjustment_type', ['percentage_discount', 'percentage_increase'])
->where('adjustment_value', '>', 0)
->where('adjustment_value', '<=', 100)
->update(['adjustment_value' => DB::raw('adjustment_value * 100')]);
}
// ─── 6. Conditions -> canonical vocabulary ────────────────────
$this->migrateConditions();
// ─── Backfill branch pivot from the single FK ─────────────────
DB::statement('
INSERT INTO pricing_rule_branches (academy_id, pricing_rule_id, branch_id, created_at, updated_at)
SELECT academy_id, id, branch_id, NOW(), NOW()
FROM pricing_rules
WHERE branch_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT DO NOTHING
');
}
/**
* Rewrite every rule's conditions onto the keys PricingService actually reads.
*
* Anything that cannot be mapped with confidence deactivates the rule instead of
* guessing — a wrong discount costs real money, an inactive one costs a phone call.
*/
private function migrateConditions(): void
{
DB::table('pricing_rules')->orderBy('id')->chunkById(200, function ($rules) {
foreach ($rules as $rule) {
$old = json_decode($rule->conditions ?? '{}', true) ?: [];
if (empty($old)) {
continue;
}
[$new, $confident] = $this->mapConditions($rule->rule_type, $old);
$update = ['conditions' => json_encode($new, JSON_UNESCAPED_UNICODE)];
if (! $confident) {
$update['is_active'] = false;
$meta = json_decode($rule->metadata ?? '{}', true) ?: [];
$meta['migration_note'] = 'تم إيقاف القاعدة تلقائياً: الشروط القديمة غير قابلة للتحويل — يرجى إعادة إنشائها';
$meta['legacy_conditions'] = $old;
$update['metadata'] = json_encode($meta, JSON_UNESCAPED_UNICODE);
}
DB::table('pricing_rules')->where('id', $rule->id)->update($update);
}
});
}
/** @return array{0: array, 1: bool} [canonical conditions, confident] */
private function mapConditions(string $ruleType, array $old): array
{
$new = [];
$confident = true;
// Range-style rules: collapse every legacy min/max spelling onto min/max.
foreach (self::RANGE_MIN as $k) {
if (isset($old[$k]) && is_numeric($old[$k])) {
$new['min'] = (int) $old[$k];
break;
}
}
foreach (self::RANGE_MAX as $k) {
if (isset($old[$k]) && is_numeric($old[$k])) {
$new['max'] = (int) $old[$k];
break;
}
}
// List-style rules: collapse onto values[].
foreach (self::LIST_KEYS as $k) {
if (! isset($old[$k]) || $old[$k] === '' || $old[$k] === null) {
continue;
}
$v = $old[$k];
$new['values'] = array_values(array_filter(is_array($v) ? $v : [$v], fn ($x) => $x !== '' && $x !== null));
break;
}
// Type-specific passthroughs.
if ($ruleType === 'seasonal') {
if (isset($old['months']) && is_array($old['months'])) {
$new['months'] = array_map('intval', $old['months']);
} elseif (isset($old['season_start']) || isset($old['season_end'])) {
$confident = false; // date range -> month list is a guess; make a human decide
}
}
if ($ruleType === 'day_time') {
if (isset($old['days']) && is_array($old['days'])) {
$new['days'] = array_map(fn ($d) => strtolower((string) $d), $old['days']);
}
foreach ([['start_time', 'after_hour'], ['end_time', 'before_hour']] as [$from, $to]) {
if (! empty($old[$from])) {
$new[$to] = (int) explode(':', (string) $old[$from])[0];
}
}
foreach (['after_hour', 'before_hour'] as $k) {
if (isset($old[$k]) && is_numeric($old[$k])) {
$new[$k] = (int) $old[$k];
}
}
}
if ($ruleType === 'enrollment_timing') {
foreach (['before_date', 'after_date'] as $k) {
if (! empty($old[$k])) {
$new[$k] = $old[$k];
}
}
foreach (['within_days', 'days_before_start'] as $k) {
if (isset($old[$k]) && is_numeric($old[$k])) {
$new['days_before_start'] = (int) $old[$k];
break;
}
}
}
// Legacy conditions existed but nothing survived the mapping — do not let the
// rule silently become an unconditional academy-wide discount.
if (empty($new)) {
$confident = false;
}
return [$new, $confident];
}
public function down(): void
{
Schema::dropIfExists('pricing_rule_branches');
DB::statement('DROP INDEX IF EXISTS pricing_rules_picker_idx');
Schema::table('pricing_rules', function (Blueprint $table) {
$table->dropColumn([
'is_pinned', 'pin_icon', 'display_order', 'recipe_key',
'is_manual', 'requires_approval', 'min_role_level',
]);
});
DB::statement('ALTER TABLE pricing_rules ALTER COLUMN is_stackable SET DEFAULT true');
}
};
<?php
use App\Domain\Shared\Models\SystemSetting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Pricing guardrails, per academy.
*
* The global discount ceiling was a hardcoded constant with a TODO; the manual
* discount caps replace the unbounded super-admin price override.
*/
return new class extends Migration
{
private const SETTINGS = [
['pricing_max_discount_percent', '50', 'الحد الأقصى لإجمالي الخصم على الفاتورة (%)'],
['pricing_manual_cap_staff', '10', 'حد الخصم اليدوي — موظف الاستقبال (%)'],
['pricing_manual_cap_manager', '25', 'حد الخصم اليدوي — مدير الفرع (%)'],
['pricing_manual_cap_owner', '100', 'حد الخصم اليدوي — مالك الأكاديمية (%)'],
];
public function up(): void
{
$academies = DB::table('academies')->pluck('id');
foreach ($academies as $academyId) {
foreach (self::SETTINGS as [$key, $value, $label]) {
DB::table('system_settings')->updateOrInsert(
['academy_id' => $academyId, 'key' => $key],
[
'value' => $value,
'group' => 'pricing',
'type' => 'integer',
'label_ar' => $label,
'is_public' => false,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}
public function down(): void
{
DB::table('system_settings')
->whereIn('key', array_column(self::SETTINGS, 0))
->delete();
}
};
...@@ -129,6 +129,9 @@ public static function getPermissionsList(): array ...@@ -129,6 +129,9 @@ public static function getPermissionsList(): array
// Pricing // Pricing
'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete', 'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete',
// Declining a discount a participant qualifies for is a separate act
// from applying one — and manual discounts need their own approval gate.
'pricing.discount_remove', 'pricing.discount_approve', 'pricing.simulate',
'base_prices.list', 'base_prices.manage', 'base_prices.list', 'base_prices.manage',
'pricing_rules.list', 'pricing_rules.manage', 'pricing_rules.list', 'pricing_rules.manage',
'promotions.list', 'promotions.create', 'promotions.update', 'promotions.delete', 'promotions.list', 'promotions.create', 'promotions.update', 'promotions.delete',
......
@props([
'candidates' => [],
'selected' => [],
'branchName' => null,
'baseAmount' => 0,
'canRemove' => true,
'manualCap' => 0,
'manualReasons'=> [],
])
@php
$grouped = collect($candidates)->groupBy('group_label');
$pinned = collect($candidates)->where('is_pinned', true)
->whereIn('state', ['applied','available','stacks','needs_approval'])
->take(4);
$applied = collect($candidates)->whereIn('rule_id', $selected);
@endphp
{{-- One Alpine scope owns the open/closed + search state; Livewire owns the data. --}}
<div x-data="{ open: false, search: '', manual: false }" class="relative">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-semibold text-gray-800">{{ __('الخصومات') }}</label>
@if($branchName)
<span class="text-xs text-emerald-700 font-medium">{{ $branchName }}</span>
@endif
</div>
{{-- Pinned quick buttons — the daily cases stay one tap --}}
@if($pinned->isNotEmpty())
<div class="flex flex-wrap gap-2 mb-2">
@foreach($pinned as $pin)
@php $isOn = in_array($pin['rule_id'], $selected); @endphp
<button type="button"
wire:click="{{ $isOn ? 'removeDiscount' : 'applyDiscount' }}({{ $pin['rule_id'] }})"
wire:loading.attr="disabled"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border-2 text-sm font-bold transition-colors
{{ $isOn ? 'bg-emerald-100 border-emerald-600 text-emerald-800'
: 'bg-white border-gray-300 text-gray-700 hover:border-emerald-400' }}">
@if($pin['icon'])<span>{{ $pin['icon'] }}</span>@endif
<span>{{ $pin['name'] }}</span>
@if($pin['value_label'])
<span dir="ltr" class="tabular-nums">{{ $pin['value_label'] }}</span>
@endif
@if($isOn)<span class="opacity-60">✕</span>@endif
</button>
@endforeach
</div>
@endif
{{-- The picker --}}
<button type="button" @click="open = !open"
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-white
border-2 border-emerald-500 rounded-lg text-sm font-semibold text-gray-700
focus:outline-none focus:ring-2 focus:ring-emerald-500">
<span>🔍 {{ __('اختر خصم...') }}</span>
<span class="text-emerald-600 text-xs" x-text="open ? '▴' : '▾'"></span>
</button>
<div x-show="open" x-cloak @click.outside="open = false" x-transition
class="mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden max-h-80 overflow-y-auto">
<div class="p-2 border-b border-gray-100 sticky top-0 bg-white">
<input type="text" x-model="search" placeholder="{{ __('ابحث...') }}"
class="w-full px-3 py-1.5 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500">
</div>
@if(count($candidates) === 0)
<p class="px-3 py-6 text-center text-sm text-gray-400">
{{ __('لا توجد خصومات متاحة لهذا الفرع') }}
</p>
@endif
@foreach($grouped as $label => $rows)
<div x-show="search === '' || @js($rows->pluck('name')->all()).some(n => n.includes(search))">
<div class="px-3 pt-2 pb-1 text-xs font-bold text-gray-400">{{ $label }}</div>
@foreach($rows as $row)
@php
$isOn = in_array($row['rule_id'], $selected);
$blocked = $row['state'] === 'blocked';
$approval = $row['state'] === 'needs_approval';
@endphp
<div data-row
x-show="search === '' || @js($row['name']).includes(search)"
class="border-t border-gray-50">
<button type="button"
@if(!$blocked) wire:click="{{ $isOn ? 'removeDiscount' : 'applyDiscount' }}({{ $row['rule_id'] }})" @endif
@if($blocked) disabled @endif
class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-start
{{ $blocked ? 'text-gray-300 cursor-not-allowed'
: ($isOn ? 'bg-emerald-50 text-emerald-800 font-bold'
: ($approval ? 'text-amber-700 hover:bg-amber-50' : 'text-gray-700 hover:bg-gray-50')) }}">
<span class="flex items-center gap-1.5">
@if($isOn)<span>✓</span>@elseif($approval)<span>⚠</span>@elseif($blocked)<span>✕</span>@endif
@if($row['icon'])<span>{{ $row['icon'] }}</span>@endif
<span>{{ $row['name'] }}</span>
</span>
<span dir="ltr" class="tabular-nums text-xs whitespace-nowrap">
{{ $row['value_label'] }}
</span>
</button>
@if($row['reason'])
<p class="px-3 pb-1.5 text-xs {{ $blocked ? 'text-gray-400' : 'text-amber-700' }}">
{{ $row['reason'] }}
</p>
@endif
</div>
@endforeach
</div>
@endforeach
{{-- Manual discount --}}
@if($manualCap > 0)
<div class="border-t border-gray-100 bg-gray-50 p-3">
<button type="button" @click="manual = !manual"
class="text-sm font-bold text-amber-700 hover:text-amber-800">
✋ {{ __('خصم استثنائي') }}
<span class="text-xs font-normal text-gray-500">
({{ __('حتى') }} {{ $manualCap }}%)
</span>
</button>
<div x-show="manual" x-cloak class="mt-2 space-y-2">
<input type="number" step="0.01" min="0" dir="ltr"
wire:model.blur="manualDiscountAmount"
placeholder="{{ __('المبلغ بالجنيه') }}"
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500">
<select wire:model.live="manualDiscountReason"
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500">
<option value="">{{ __('سبب الخصم...') }}</option>
@foreach($manualReasons as $reason)
<option value="{{ $reason['value'] }}">{{ $reason['label'] }}</option>
@endforeach
</select>
<textarea wire:model.blur="manualDiscountNote" rows="2"
placeholder="{{ __('ملاحظة (مطلوبة لبعض الأسباب)') }}"
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500"></textarea>
@error('manualDiscountAmount')
<p class="text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
@endif
</div>
{{-- What is actually applied --}}
@if($applied->isNotEmpty())
<div class="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-dashed border-gray-200">
<span class="text-xs text-gray-500 font-semibold">{{ __('المُطبق:') }}</span>
@foreach($applied as $row)
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full
bg-emerald-100 border-2 border-emerald-600 text-emerald-800 text-sm font-bold">
{{ $row['name'] }}
@if($row['value_label'])
<span dir="ltr" class="tabular-nums">{{ $row['value_label'] }}</span>
@endif
@if($canRemove)
<button type="button" wire:click="removeDiscount({{ $row['rule_id'] }})"
class="opacity-60 hover:opacity-100" title="{{ __('إلغاء') }}">✕</button>
@endif
</span>
@endforeach
</div>
@endif
</div>
...@@ -31,6 +31,9 @@ ...@@ -31,6 +31,9 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
<style> <style>
/* x-cloak was used throughout without this rule, so cloaked elements
flashed visible before Alpine booted. */
[x-cloak] { display: none !important; }
:root { :root {
--brand-primary: {{ $brandPrimary }}; --brand-primary: {{ $brandPrimary }};
--brand-secondary: {{ $brandSecondary }}; --brand-secondary: {{ $brandSecondary }};
......
...@@ -21,12 +21,10 @@ class="w-full sm:w-auto px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm ho ...@@ -21,12 +21,10 @@ class="w-full sm:w-auto px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm ho
<div class="mt-2 text-green-600 text-xs space-y-1"> <div class="mt-2 text-green-600 text-xs space-y-1">
<p>{{ $result['name'] }}</p> <p>{{ $result['name'] }}</p>
<p> <p>
@if($result['type'] === 'percentage_discount') @if($result['type'] === 'fixed_price')
{{ __('خصم') }} {{ $result['value'] }}% {{ __('سعر ثابت') }} {{ $result['value_label'] }}
@elseif($result['type'] === 'fixed_discount') @else
{{ __('خصم') }} {{ format_money($result['value']) }} {{ __('خصم') }} {{ $result['value_label'] }}
@elseif($result['type'] === 'fixed_price')
{{ __('سعر ثابت') }} {{ format_money($result['value']) }}
@endif @endif
</p> </p>
@if($result['remaining']) @if($result['remaining'])
......
<div> <div>
{{-- Header --}}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('إنشاء قاعدة تسعير') }}</h1> <h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('إنشاء خصم') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('إنشاء قاعدة خصم أو تعديل سعر جديدة') }}</p> <p class="text-sm text-gray-500 mt-1">{{ __('ابدأ من وصفة جاهزة وعدّل عليها') }}</p>
</div> </div>
<a href="{{ route('pricing.rules') }}" wire:navigate
class="text-sm text-gray-500 hover:text-gray-700">{{ __('العودة للقائمة') }}</a>
</div> </div>
{{-- Flash Messages --}}
@if(session('error')) @if(session('error'))
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm"> <div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm">
{{ session('error') }} {{ session('error') }}
</div>
@endif
{{-- Success State --}}
@if($completed)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<div class="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div> </div>
<h2 class="text-xl font-bold text-gray-800 mb-2">{{ __('تم إنشاء القاعدة بنجاح') }}</h2> @endif
<p class="text-gray-500 mb-6">{{ __('تم حفظ قاعدة التسعير وستُطبق على العمليات القادمة') }}</p>
<a href="{{ route('pricing.rules') }}" wire:navigate
class="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium transition-colors">
{{ __('العودة لقائمة القواعد') }}
</a>
</div>
@else
{{-- Step Indicator --}} {{-- ─────────── Stage: done ─────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6"> @if($stage === 'done')
<div class="flex items-center justify-between"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
@foreach($this->getStepLabels() as $num => $label) <div class="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<div class="flex items-center {{ !$loop->last ? 'flex-1' : '' }}"> <svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button wire:click="goToStep({{ $num }})" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
@if($num >= $currentStep) disabled @endif </svg>
class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'cursor-default' }}"> </div>
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors <h2 class="text-xl font-bold text-gray-800 mb-2">{{ __('تم إنشاء الخصم') }}</h2>
{{ $num === $currentStep ? 'bg-emerald-600 text-white' : '' }} <p class="text-gray-500 mb-6">{{ $this->sentence }}</p>
{{ $num < $currentStep ? 'bg-green-500 text-white' : '' }} <div class="flex items-center justify-center gap-3">
{{ $num > $currentStep ? 'bg-gray-200 text-gray-500' : '' }}"> <a href="{{ route('pricing.rules') }}" wire:navigate
@if($num < $currentStep) class="px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
<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="M5 13l4 4L19 7"/> </a>
</svg> <button type="button" wire:click="backToRecipes"
@else class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
{{ $num }} {{ __('إنشاء خصم آخر') }}
@endif
</div>
<span class="text-xs font-medium hidden sm:inline
{{ $num === $currentStep ? 'text-emerald-600' : '' }}
{{ $num < $currentStep ? 'text-green-600' : '' }}
{{ $num > $currentStep ? 'text-gray-400' : '' }}">
{{ __($label) }}
</span>
</button> </button>
@if(!$loop->last)
<div class="flex-1 h-0.5 mx-3 {{ $num < $currentStep ? 'bg-green-500' : 'bg-gray-200' }}"></div>
@endif
</div> </div>
@endforeach
</div> </div>
</div>
{{-- Step Content --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
{{-- Step 1: Rule Type --}} {{-- ─────────── Stage: recipes ─────────── --}}
@if($currentStep === 1) @elseif($stage === 'recipes')
<div> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ __('اختر نوع القاعدة') }}</h2> <h2 class="text-base font-bold text-gray-800 mb-1">{{ __('اختر وصفة خصم') }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ __('حدد نوع الشرط الذي سيتم التحقق منه عند تطبيق الخصم') }}</p> <p class="text-sm text-gray-500 mb-5">{{ __('كل وصفة جاهزة — غيّر رقمين واحفظ') }}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
@foreach($ruleTypes as $type) @foreach($recipes as $key => $recipe)
<label class="relative cursor-pointer"> <button type="button" wire:click="chooseRecipe('{{ $key }}')"
<input type="radio" wire:model="ruleType" value="{{ $type->value }}" class="peer sr-only"> class="p-4 rounded-xl border-2 border-gray-200 hover:border-emerald-500 hover:bg-emerald-50
<div class="p-4 border-2 rounded-xl transition-all text-center transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500">
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 <div class="text-2xl leading-9">{{ $recipe['icon'] }}</div>
border-gray-200 hover:border-gray-300"> <div class="text-sm font-bold text-gray-800">{{ $recipe['name_ar'] }}</div>
<div class="font-medium text-gray-800">{{ $type->label() }}</div> <div class="text-xs text-gray-400 mt-0.5">{{ $recipe['hint_ar'] }}</div>
<div class="text-xs text-gray-500 mt-1">{{ $type->value }}</div> </button>
</div>
</label>
@endforeach @endforeach
</div> </div>
@error('ruleType') <div class="mt-5 pt-5 border-t border-gray-100 text-center">
<p class="text-red-500 text-sm mt-2">{{ $message }}</p> <button type="button" wire:click="startBlank"
@enderror class="text-sm text-gray-600 hover:text-emerald-700 font-medium">
</div> {{ __('أو ابدأ من الصفر') }}
@endif </button>
{{-- Step 2: Conditions --}}
@if($currentStep === 2)
<div>
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ __('شروط القاعدة') }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ __('حدد الشروط التي يجب تحققها لتطبيق هذه القاعدة') }}</p>
@if($ruleType === 'age')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الحد الأدنى للعمر') }}</label>
<input type="number" wire:model="minAge" dir="ltr" min="1" max="100"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@error('minAge') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الحد الأقصى للعمر') }}</label>
<input type="number" wire:model="maxAge" dir="ltr" min="1" max="100"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@error('maxAge') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div> </div>
</div>
@elseif($ruleType === 'membership_duration') {{-- ─────────── Stage: build ─────────── --}}
<div> @else
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الحد الأدنى لأشهر العضوية') }}</label> <div class="grid grid-cols-1 lg:grid-cols-5 gap-5">
<input type="number" wire:model="minMonths" dir="ltr" min="1"
class="w-full max-w-xs px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@error('minMonths') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@elseif($ruleType === 'family_size') {{-- The sentence --}}
<div> <div class="lg:col-span-3 space-y-5">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الحد الأدنى لعدد الأبناء') }}</label> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<input type="number" wire:model="minChildren" dir="ltr" min="2" <div class="flex items-center justify-between mb-4">
class="w-full max-w-xs px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"> <h2 class="text-base font-bold text-gray-800">{{ __('الخصم') }}</h2>
@error('minChildren') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror <button type="button" wire:click="backToRecipes"
</div> class="text-xs text-gray-500 hover:text-gray-700">{{ __('تغيير الوصفة') }}</button>
</div>
@elseif($ruleType === 'sibling_order') <div class="p-4 bg-emerald-50 border border-emerald-200 rounded-xl mb-5">
<div> <p class="text-emerald-900 font-semibold leading-8">{{ $this->sentence }}</p>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ترتيب الأخ (مثال: 2 = الأخ الثاني)') }}</label> </div>
<input type="number" wire:model="orderNumber" dir="ltr" min="1"
class="w-full max-w-xs px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@error('orderNumber') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@elseif($ruleType === 'gender') <div class="space-y-4">
<div> {{-- Name --}}
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('الجنس المستهدف') }}</label> <div>
<div class="flex gap-4"> <label class="block text-sm font-medium text-gray-700 mb-1">
<label class="relative cursor-pointer"> {{ __('اسم الخصم') }} <span class="text-red-500">*</span>
<input type="radio" wire:model="targetGender" value="male" class="peer sr-only"> </label>
<div class="px-6 py-3 border-2 rounded-xl transition-all <input type="text" wire:model.live.debounce.400ms="nameAr"
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
border-gray-200 hover:border-gray-300 font-medium"> @error('nameAr') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
{{ __('ذكر') }}
</div> </div>
</label>
<label class="relative cursor-pointer"> {{-- Amount + type --}}
<input type="radio" wire:model="targetGender" value="female" class="peer sr-only"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="px-6 py-3 border-2 rounded-xl transition-all <div>
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نوع التعديل') }}</label>
border-gray-200 hover:border-gray-300 font-medium"> <select wire:model.live="adjustmentType"
{{ __('أنثى') }} class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
<option value="percentage_discount">{{ __('خصم نسبة مئوية') }}</option>
<option value="fixed_discount">{{ __('خصم مبلغ ثابت') }}</option>
<option value="fixed_price">{{ __('سعر ثابت') }}</option>
<option value="percentage_increase">{{ __('زيادة نسبة مئوية') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('القيمة') }}
<span class="text-gray-400">
({{ in_array($adjustmentType, ['fixed_discount','fixed_price']) ? __('ج.م') : '%' }})
</span>
</label>
<input type="number" step="0.01" min="0" dir="ltr"
wire:model.live.debounce.400ms="amount"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
@error('amount') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div> </div>
</label>
</div>
@error('targetGender') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@elseif($ruleType === 'branch') {{-- Who qualifies --}}
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الفرع المستهدف') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('لمن؟') }}</label>
<select wire:model="targetBranchId" <select wire:model.live="ruleType"
class="w-full max-w-md px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 mb-3">
<option value="">{{ __('اختر الفرع') }}</option> @foreach($ruleTypes as $rt)
@foreach($branches as $branch) <option value="{{ $rt->value }}">{{ $rt->label() }}</option>
<option value="{{ $branch->id }}">{{ $branch->name_ar }}</option> @endforeach
@endforeach </select>
</select>
@error('targetBranchId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@elseif($ruleType === 'classification') @include('livewire.pricing.partials.condition-fields', [
<div> 'kind' => $schemaKind,
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('التصنيف المستهدف') }}</label> 'unit' => $schemaUnit,
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3"> ])
@php @error('conditions') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
$classifications = [
'regular' => 'عادي',
'vip' => 'VIP',
'scholarship' => 'منحة',
'staff_child' => 'ابن موظف',
'trial' => 'تجريبي',
];
@endphp
@foreach($classifications as $value => $label)
<label class="relative cursor-pointer">
<input type="radio" wire:model="targetClassification" value="{{ $value }}" class="peer sr-only">
<div class="px-4 py-3 border-2 rounded-xl transition-all text-center
peer-checked:border-emerald-500 peer-checked:bg-emerald-50
border-gray-200 hover:border-gray-300 font-medium text-sm">
{{ __($label) }}
</div> </div>
</label>
@endforeach
</div>
@error('targetClassification') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@else {{-- Branches --}}
{{-- Generic conditions for: enrollment_timing, enrollment_volume, seasonal, day_time, loyalty, custom --}} <div>
<div> <label class="block text-sm font-medium text-gray-700 mb-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الشروط (JSON)') }}</label> {{ __('الفروع') }}
<textarea wire:model="genericConditions" rows="5" dir="ltr" <span class="text-xs text-gray-400">{{ __('اتركه فارغاً للتطبيق على كل الفروع') }}</span>
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 font-mono text-sm" </label>
placeholder='{"key": "value"}'></textarea> <div class="flex flex-wrap gap-2">
<p class="text-xs text-gray-400 mt-1">{{ __('أدخل الشروط بصيغة JSON. اتركه فارغاً إذا لا توجد شروط إضافية.') }}</p> @foreach($branches as $branch)
</div> <label class="cursor-pointer">
@endif <input type="checkbox" value="{{ $branch->id }}"
</div> wire:model.live="branchIds" class="peer sr-only">
@endif <span class="inline-block px-3 py-1.5 rounded-full border-2 text-sm font-medium
border-gray-200 text-gray-600
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 peer-checked:text-emerald-700">
{{ $branch->name_ar }}
</span>
</label>
@endforeach
</div>
</div>
{{-- Step 3: Adjustment --}} {{-- Stacking --}}
@if($currentStep === 3) <div class="pt-4 border-t border-gray-100">
<div> <label class="block text-sm font-medium text-gray-700 mb-2">
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ __('نوع التعديل') }}</h2> {{ __('يُجمع مع خصومات أخرى؟') }}
<p class="text-sm text-gray-500 mb-6">{{ __('حدد كيف سيتم تعديل السعر عند تطبيق هذه القاعدة') }}</p> </label>
<div class="inline-flex rounded-lg border-2 border-gray-200 overflow-hidden">
<button type="button" wire:click="$set('isStackable', false)"
class="px-4 py-1.5 text-sm font-bold transition-colors
{{ !$isStackable ? 'bg-emerald-600 text-white' : 'bg-white text-gray-500' }}">
{{ __('لا — الأفضل فقط') }}
</button>
<button type="button" wire:click="$set('isStackable', true)"
class="px-4 py-1.5 text-sm font-bold transition-colors
{{ $isStackable ? 'bg-emerald-600 text-white' : 'bg-white text-gray-500' }}">
{{ __('نعم') }}
</button>
</div>
<p class="text-xs text-gray-400 mt-1.5">
{{ __('معظم الخصومات لا تُجمع — يُطبق الأعلى قيمة فقط') }}
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6"> {{-- Pin + dates --}}
@foreach($adjustmentTypes as $type) <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-4 border-t border-gray-100">
<label class="relative cursor-pointer"> <div>
<input type="radio" wire:model="adjustmentType" value="{{ $type->value }}" class="peer sr-only"> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('من تاريخ') }} <span class="text-red-500">*</span></label>
<div class="p-4 border-2 rounded-xl transition-all <input type="date" wire:model="effectiveFrom" dir="ltr"
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
border-gray-200 hover:border-gray-300"> @error('effectiveFrom') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<div class="font-medium text-gray-800">{{ $type->label() }}</div> </div>
</div> <div>
</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('إلى تاريخ') }}</label>
@endforeach <input type="date" wire:model="effectiveTo" dir="ltr"
</div> class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
@error('adjustmentType') <p class="text-red-500 text-xs mb-4">{{ $message }}</p> @enderror @error('effectiveTo') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('حد الاستخدام') }}</label>
<input type="number" min="1" wire:model="usageLimit" dir="ltr"
placeholder="{{ __('بلا حد') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <label class="flex items-center gap-2 cursor-pointer">
<div> <input type="checkbox" wire:model.live="isPinned"
<label class="block text-sm font-medium text-gray-700 mb-1"> class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
{{ __('القيمة') }} <span class="text-sm text-gray-700">{{ __('تثبيت كزر سريع في شاشة الدفع') }}</span>
@if(in_array($adjustmentType, ['percentage_discount', 'percentage_increase'])) </label>
<span class="text-gray-400">({{ __('%') }})</span> </div>
@else
<span class="text-gray-400">({{ __('ج.م') }})</span>
@endif
</label>
<input type="number" wire:model="adjustmentValue" dir="ltr" min="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@if(in_array($adjustmentType, ['fixed_discount', 'fixed_price']))
<p class="text-xs text-gray-400 mt-1">{{ __('القيمة بالقروش (100 قرش = 1 جنيه)') }}</p>
@endif
@error('adjustmentValue') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الحد الأقصى للخصم %') }}</label> <div class="flex items-center gap-3">
<input type="number" wire:model="maxDiscountPercent" dir="ltr" min="1" max="100" <button type="button" wire:click="save"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" wire:loading.attr="disabled" wire:target="save"
placeholder="{{ __('اختياري') }}"> class="px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium disabled:opacity-50">
@error('maxDiscountPercent') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror <span wire:loading.remove wire:target="save">{{ __('حفظ الخصم') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ...') }}</span>
</button>
<button type="button" wire:click="backToRecipes"
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
{{ __('إلغاء') }}
</button>
</div> </div>
</div> </div>
</div>
@endif
{{-- Step 4: Scope & Priority --}} {{-- Simulator --}}
@if($currentStep === 4) <div class="lg:col-span-2">
<div> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 lg:sticky lg:top-4">
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ __('النطاق والأولوية') }}</h2> <h2 class="text-base font-bold text-gray-800 mb-1">{{ __('جرّبها على مشترك حقيقي') }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ __('حدد تفاصيل القاعدة ونطاق تطبيقها') }}</p> <p class="text-xs text-gray-500 mb-4">{{ __('شوف الحساب قبل ما تحفظ') }}</p>
<div class="space-y-4"> @if($audience)
{{-- Name --}} <div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800 font-semibold">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> 👥 {{ __('تنطبق على') }} {{ $audience['matched'] }}
<div> {{ __('مشترك من') }} {{ $audience['total'] }}
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label> @if($audience['total'] > 0 && $audience['matched'] === $audience['total'])
<input type="text" wire:model="nameAr" <div class="text-xs font-normal text-blue-700 mt-1">
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"> ⚠ {{ __('تنطبق على الجميع — تأكد أن هذا مقصود') }}
@error('nameAr') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror </div>
</div> @endif
<div> </div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالإنجليزية') }}</label> @endif
<input type="text" wire:model="name" dir="ltr"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Target & Branch --}} <div class="space-y-3 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <select wire:model.live="simProgramId"
<div> class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نوع الهدف') }}</label> <option value="">{{ __('اختر برنامج...') }}</option>
<select wire:model="targetType" @foreach($programs as $program)
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"> <option value="{{ $program->id }}">{{ $program->name_ar }}</option>
<option value="">{{ __('الكل') }}</option>
<option value="program">{{ __('برنامج') }}</option>
<option value="activity">{{ __('نشاط') }}</option>
<option value="product">{{ __('منتج') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('معرف الهدف') }}</label>
<input type="number" wire:model="targetId" dir="ltr"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
placeholder="{{ __('اختياري') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الفرع') }}</label>
<select wire:model="branchId"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">{{ __('جميع الفروع') }}</option>
@foreach($branches as $branch)
<option value="{{ $branch->id }}">{{ $branch->name_ar }}</option>
@endforeach @endforeach
</select> </select>
</div>
</div>
{{-- Priority & Stackable --}} <input type="number" wire:model.live.debounce.600ms="simParticipantId" dir="ltr"
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> placeholder="{{ __('رقم المشترك') }}"
<div> class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الأولوية') }} <span class="text-red-500">*</span></label>
<input type="number" wire:model="priority" dir="ltr" min="1" max="100"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<p class="text-xs text-gray-400 mt-1">{{ __('رقم أقل = أولوية أعلى') }}</p>
@error('priority') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('حد الاستخدام') }}</label>
<input type="number" wire:model="usageLimit" dir="ltr" min="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
placeholder="{{ __('بدون حد') }}">
@error('usageLimit') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div class="flex items-end gap-6 pb-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="isStackable"
class="w-5 h-5 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
<span class="text-sm font-medium text-gray-700">{{ __('قابلة للتراكم') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="isActive"
class="w-5 h-5 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
<span class="text-sm font-medium text-gray-700">{{ __('مفعّلة') }}</span>
</label>
</div>
</div>
{{-- Dates --}} @if(!empty($simulation['error']))
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
<div> {{ $simulation['error'] }}
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ البدء') }} <span class="text-red-500">*</span></label> </div>
<input type="date" wire:model="effectiveFrom" dir="ltr" @elseif(!empty($simulation))
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"> <div class="border-t border-gray-100 pt-4">
@error('effectiveFrom') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror <div class="font-bold text-gray-800 text-sm mb-2">
</div> {{ $simulation['participant'] }} — {{ $simulation['program'] }}
<div> </div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ الانتهاء') }}</label>
<input type="date" wire:model="effectiveTo" dir="ltr"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<p class="text-xs text-gray-400 mt-1">{{ __('اتركه فارغاً لقاعدة مفتوحة') }}</p>
@error('effectiveTo') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
@endif
{{-- Step 5: Review --}} <div class="flex justify-between py-1 text-sm text-gray-700">
@if($currentStep === 5) <span>{{ __('السعر الأساسي') }}</span>
<div> <span dir="ltr" class="tabular-nums">{{ number_format($simulation['base'] / 100, 2) }}</span>
<h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('مراجعة القاعدة') }}</h2> </div>
<div class="space-y-4"> @foreach($simulation['applied'] as $line)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="flex justify-between py-1 text-sm text-emerald-700">
<div class="p-4 bg-gray-50 rounded-lg"> <span>{{ $line['rule_name'] }}</span>
<div class="text-xs text-gray-500 mb-1">{{ __('الاسم') }}</div> <span dir="ltr" class="tabular-nums">−{{ number_format($line['discount'] / 100, 2) }}</span>
<div class="font-medium text-gray-800">{{ $nameAr }}</div> </div>
@if($name) @endforeach
<div class="text-sm text-gray-500">{{ $name }}</div>
@endif
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('نوع القاعدة') }}</div>
<div class="font-medium text-gray-800">{{ $this->ruleTypeLabel }}</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('نوع التعديل') }}</div>
<div class="font-medium text-gray-800">{{ $this->adjustmentTypeLabel }}</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('قيمة التعديل') }}</div>
<div class="font-medium text-gray-800" dir="ltr">
@if(in_array($adjustmentType, ['percentage_discount', 'percentage_increase']))
{{ $adjustmentValue }}%
@else
{{ number_format($adjustmentValue / 100, 2) }} {{ __('ج.م') }}
@endif
</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('الأولوية') }}</div>
<div class="font-medium text-gray-800">{{ $priority }}</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('قابلة للتراكم') }}</div>
<div class="font-medium text-gray-800">{{ $isStackable ? __('نعم') : __('لا') }}</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('تاريخ البدء') }}</div>
<div class="font-medium text-gray-800" dir="ltr">{{ $effectiveFrom }}</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ __('تاريخ الانتهاء') }}</div>
<div class="font-medium text-gray-800" dir="ltr">{{ $effectiveTo ?: __('مفتوح') }}</div>
</div>
</div>
@if($maxDiscountPercent) <div class="flex justify-between py-1 text-sm {{ $simulation['qualifies'] ? 'text-emerald-700 font-semibold' : 'text-gray-400' }}">
<div class="p-4 bg-amber-50 border border-amber-200 rounded-lg"> <span>{{ $simulation['draft_name'] }} {{ __('(جديد)') }}</span>
<div class="text-sm text-amber-700"> <span>{{ $simulation['qualifies'] ? __('ينطبق') : __('لا ينطبق') }}</span>
{{ __('الحد الأقصى للخصم:') }} {{ $maxDiscountPercent }}% </div>
</div>
</div>
@endif
</div>
</div>
@endif
{{-- Navigation Buttons --}} <div class="flex justify-between border-t-2 border-gray-800 mt-2 pt-2 font-bold text-gray-900">
<div class="flex items-center justify-between mt-8 pt-6 border-t border-gray-200"> <span>{{ __('الإجمالي الحالي') }}</span>
<div> <span dir="ltr" class="tabular-nums">{{ number_format($simulation['current'] / 100, 2) }} {{ __('ج.م') }}</span>
@if($currentStep > 1) </div>
<button wire:click="previousStep" </div>
class="px-5 py-2.5 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 font-medium transition-colors"> @else
{{ __('السابق') }} <p class="text-sm text-gray-400 text-center py-6">
</button> {{ __('اختر برنامج ومشترك لعرض الحساب') }}
@endif </p>
</div> @endif
<div> </div>
@if($currentStep < $totalSteps)
<button wire:click="nextStep" wire:loading.attr="disabled" wire:target="nextStep"
class="px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="nextStep">{{ __('التالي') }}</span>
<span wire:loading wire:target="nextStep">{{ __('جارٍ التحقق...') }}</span>
</button>
@else
<button wire:click="confirm" wire:loading.attr="disabled" wire:target="confirm"
class="px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="confirm">{{ __('تأكيد') }}</span>
<span wire:loading wire:target="confirm">{{ __('جارٍ الحفظ...') }}</span>
</button>
@endif
</div> </div>
</div> </div>
</div>
@endif @endif
</div> </div>
{{-- Condition inputs, driven entirely by ConditionSchema::kind() --}}
@php
use App\Domain\Pricing\Support\ConditionSchema;
@endphp
@if($kind === ConditionSchema::KIND_RANGE)
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">
{{ __('من') }} @if($unit)<span class="text-gray-400">({{ $unit }})</span>@endif
</label>
<input type="number" min="0" dir="ltr" wire:model.live.debounce.400ms="conditions.min"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
@error('conditions.min') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">
{{ __('إلى') }} @if($unit)<span class="text-gray-400">({{ $unit }})</span>@endif
</label>
<input type="number" min="0" dir="ltr" wire:model.live.debounce.400ms="conditions.max"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
@error('conditions.max') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@elseif($kind === ConditionSchema::KIND_LIST)
@php
$options = match($ruleType) {
'gender' => collect(ConditionSchema::GENDERS)->mapWithKeys(fn($g) => [$g => $g === 'male' ? __('ذكور') : __('إناث')]),
'classification' => collect(ConditionSchema::CLASSIFICATIONS)->mapWithKeys(fn($c) => [$c => ConditionSchema::classificationLabel($c)]),
default => $branches->mapWithKeys(fn($b) => [(string) $b->id => $b->name_ar]),
};
@endphp
<div class="flex flex-wrap gap-2">
@foreach($options as $value => $label)
<label class="cursor-pointer">
<input type="checkbox" value="{{ $value }}" wire:model.live="conditions.values" class="peer sr-only">
<span class="inline-block px-3 py-1.5 rounded-full border-2 text-sm font-medium
border-gray-200 text-gray-600
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 peer-checked:text-emerald-700">
{{ $label }}
</span>
</label>
@endforeach
</div>
@error('conditions.values') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@elseif($kind === ConditionSchema::KIND_MONTHS)
<div class="flex flex-wrap gap-2">
@for($m = 1; $m <= 12; $m++)
<label class="cursor-pointer">
<input type="checkbox" value="{{ $m }}" wire:model.live="conditions.months" class="peer sr-only">
<span class="inline-block px-3 py-1.5 rounded-full border-2 text-sm font-medium
border-gray-200 text-gray-600
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 peer-checked:text-emerald-700">
{{ ConditionSchema::monthLabel($m) }}
</span>
</label>
@endfor
</div>
@error('conditions.months') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@elseif($kind === ConditionSchema::KIND_SCHED)
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
@foreach(ConditionSchema::WEEKDAYS as $day)
<label class="cursor-pointer">
<input type="checkbox" value="{{ $day }}" wire:model.live="conditions.days" class="peer sr-only">
<span class="inline-block px-3 py-1.5 rounded-full border-2 text-sm font-medium
border-gray-200 text-gray-600
peer-checked:border-emerald-500 peer-checked:bg-emerald-50 peer-checked:text-emerald-700">
{{ ConditionSchema::dayLabel($day) }}
</span>
</label>
@endforeach
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('بعد الساعة') }}</label>
<input type="number" min="0" max="23" dir="ltr" wire:model.live.debounce.400ms="conditions.after_hour"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('قبل الساعة') }}</label>
<input type="number" min="0" max="23" dir="ltr" wire:model.live.debounce.400ms="conditions.before_hour"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
</div>
</div>
@elseif($kind === ConditionSchema::KIND_TIMING)
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('قبل البداية بـ (يوم)') }}</label>
<input type="number" min="1" max="365" dir="ltr" wire:model.live.debounce.400ms="conditions.days_before_start"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('يبدأ من') }}</label>
<input type="date" dir="ltr" wire:model.live="conditions.after_date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('ينتهي في') }}</label>
<input type="date" dir="ltr" wire:model.live="conditions.before_date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500">
</div>
</div>
@else
<div class="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
{{ __('خصم يدوي — لا يُطبق تلقائياً، يُختار من قائمة الخصومات عند الدفع') }}
</div>
@endif
...@@ -82,7 +82,7 @@ class="w-full px-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f ...@@ -82,7 +82,7 @@ class="w-full px-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f
</td> </td>
<td class="px-4 py-3 text-center font-medium" dir="ltr"> <td class="px-4 py-3 text-center font-medium" dir="ltr">
@if(in_array($rule->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease])) @if(in_array($rule->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease]))
{{ $rule->adjustment_value }}% {{ \App\Domain\Pricing\Support\Percent::label($rule->adjustment_value) }}
@else @else
{{ number_format($rule->adjustment_value / 100, 2) }} {{ __('ج.م') }} {{ number_format($rule->adjustment_value / 100, 2) }} {{ __('ج.م') }}
@endif @endif
...@@ -174,7 +174,7 @@ class="px-2 py-1 text-xs rounded-full shrink-0 {{ $rule->is_active ? 'bg-green-1 ...@@ -174,7 +174,7 @@ class="px-2 py-1 text-xs rounded-full shrink-0 {{ $rule->is_active ? 'bg-green-1
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span dir="ltr" class="font-medium text-gray-800"> <span dir="ltr" class="font-medium text-gray-800">
@if(in_array($rule->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease])) @if(in_array($rule->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease]))
{{ $rule->adjustment_value }}% {{ \App\Domain\Pricing\Support\Percent::label($rule->adjustment_value) }}
@else @else
{{ number_format($rule->adjustment_value / 100, 2) }} {{ __('ج.م') }} {{ number_format($rule->adjustment_value / 100, 2) }} {{ __('ج.م') }}
@endif @endif
......
...@@ -96,7 +96,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin ...@@ -96,7 +96,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</td> </td>
<td class="px-4 py-3 text-center text-xs" dir="ltr"> <td class="px-4 py-3 text-center text-xs" dir="ltr">
@if(in_array($promotion->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease])) @if(in_array($promotion->adjustment_type, [\App\Domain\Pricing\Enums\AdjustmentType::PercentageDiscount, \App\Domain\Pricing\Enums\AdjustmentType::PercentageIncrease]))
{{ $promotion->adjustment_value }}% {{ $promotion->adjustment_type->label() }} {{ \App\Domain\Pricing\Support\Percent::label($promotion->adjustment_value) }} {{ $promotion->adjustment_type->label() }}
@else @else
{{ number_format($promotion->adjustment_value / 100, 2) }} {{ __('ج.م') }} {{ number_format($promotion->adjustment_value / 100, 2) }} {{ __('ج.م') }}
@endif @endif
......
...@@ -364,12 +364,21 @@ class="inline-flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded- ...@@ -364,12 +364,21 @@ class="inline-flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-
<span class="text-sm font-medium text-gray-700" dir="ltr">{{ number_format($upcoming['amount'] / 100, 2) }} {{ __('ج.م') }}</span> <span class="text-sm font-medium text-gray-700" dir="ltr">{{ number_format($upcoming['amount'] / 100, 2) }} {{ __('ج.م') }}</span>
@endif @endif
@if($upcoming['type'] === 'renewal' && $upcoming['due_date']->isPast()) @if($upcoming['type'] === 'renewal' && $upcoming['due_date']->isPast())
<button wire:click="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})" @if($pricing_enrollment_id === $upcoming['enrollment_id'])
wire:loading.attr="disabled" <button wire:click="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})"
class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 font-medium transition-colors"> wire:loading.attr="disabled"
<span wire:loading.remove wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('إنشاء فاتورة') }}</span> class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 font-medium transition-colors">
<span wire:loading wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span> <span wire:loading.remove wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('تأكيد وإنشاء الفاتورة') }}</span>
</button> <span wire:loading wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span>
</button>
@else
<button wire:click="prepareRenewalPricing({{ $upcoming['enrollment_id'] }})"
wire:loading.attr="disabled"
class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 font-medium transition-colors">
<span wire:loading.remove wire:target="prepareRenewalPricing({{ $upcoming['enrollment_id'] }})">{{ __('إنشاء فاتورة') }}</span>
<span wire:loading wire:target="prepareRenewalPricing({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span>
</button>
@endif
@else @else
<span class="text-xs px-2 py-0.5 rounded-full {{ $upcoming['due_date']->isPast() ? 'bg-red-100 text-red-700' : ($upcoming['due_date']->isToday() ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600') }}" dir="ltr"> <span class="text-xs px-2 py-0.5 rounded-full {{ $upcoming['due_date']->isPast() ? 'bg-red-100 text-red-700' : ($upcoming['due_date']->isToday() ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600') }}" dir="ltr">
{{ $upcoming['due_date']->format('Y-m-d') }} {{ $upcoming['due_date']->format('Y-m-d') }}
...@@ -378,6 +387,32 @@ class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 f ...@@ -378,6 +387,32 @@ class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 f
</div> </div>
</div> </div>
@endforeach @endforeach
{{-- Discount picker — shown once a renewal is being priced --}}
@if($pricing_enrollment_id && $discountCandidates)
<div class="mt-3 p-3 bg-white rounded-lg border-2 border-emerald-200">
<x-pricing.discount-picker
:candidates="$discountCandidates"
:selected="$selectedDiscountIds"
:branch-name="$branchName"
:base-amount="$discountBaseAmount"
:can-remove="auth()->user()?->can('pricing.discount_remove') ?? false"
:manual-cap="$this->manualDiscountCap"
:manual-reasons="$this->manualReasonOptions" />
@if($manualDiscountError)
<p class="mt-2 text-xs text-red-600">{{ $manualDiscountError }}</p>
@endif
<div class="flex justify-between items-center mt-3 pt-3 border-t border-gray-200 text-sm">
<span class="text-gray-600">{{ __('الإجمالي بعد الخصم') }}</span>
<span class="font-bold text-gray-900 tabular-nums" dir="ltr">
{{ number_format(max(0, $discountBaseAmount - $this->selectedDiscountTotal()) / 100, 2) }}
{{ __('ج.م') }}
</span>
</div>
</div>
@endif
</div> </div>
</div> </div>
@endif @endif
......
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