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,10 +153,34 @@ public function scopeEffectiveOn(Builder $query, $date): Builder ...@@ -88,10 +153,34 @@ 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
{ {
......
<?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(),
];
}
}
This diff is collapsed.
<?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,
]; ];
......
...@@ -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'])
......
{{-- 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,13 +364,22 @@ class="inline-flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded- ...@@ -364,13 +364,22 @@ 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())
@if($pricing_enrollment_id === $upcoming['enrollment_id'])
<button wire:click="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})" <button wire:click="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})"
wire:loading.attr="disabled" 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"> 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="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('إنشاء فاتورة') }}</span> <span wire:loading.remove wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('تأكيد وإنشاء الفاتورة') }}</span>
<span wire:loading wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span> <span wire:loading wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span>
</button> </button>
@else @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
<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') }}
</span> </span>
...@@ -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