Commit 9a8823b4 authored by Fares's avatar Fares

feat(pricing): enhance special discounts with conditions, bonuses, and date ranges

Expanded the special discounts system to support:
- Discount types: percentage, fixed amount, or free subscription
- Date range (effective_from/effective_to) for time-bounded offers
- Conditions: none (direct), full_payment, or min_payment threshold
- Bonus: free subscription years granted when discount activates
- Applies-to targeting: membership_fee, subscription, or all

Added SpecialDiscountService for evaluating conditional discounts and
applying free subscription bonuses on member.activated event.

Updated form UI with dynamic visibility and validation.

Migration: Phase_98_003 adds new columns to special_discounts table.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e0e2fdd8
...@@ -418,21 +418,51 @@ final class BillingService ...@@ -418,21 +418,51 @@ final class BillingService
// ── 2b. Special Discount ── // ── 2b. Special Discount ──
if (!empty($member['special_discount_id'])) { if (!empty($member['special_discount_id'])) {
$discountRow = $db->selectOne( $discountRow = $db->selectOne(
"SELECT sd.name_ar, sd.discount_percentage FROM special_discounts sd WHERE sd.id = ?", "SELECT * FROM special_discounts sd WHERE sd.id = ? AND sd.is_active = 1",
[(int) $member['special_discount_id']] [(int) $member['special_discount_id']]
); );
if ($discountRow) { if ($discountRow) {
$today = date('Y-m-d');
$inRange = (empty($discountRow['effective_from']) || $today >= $discountRow['effective_from'])
&& (empty($discountRow['effective_to']) || $today <= $discountRow['effective_to']);
if ($inRange) {
$discType = $discountRow['discount_type'] ?? 'percentage';
if ($discType === 'percentage') {
$discountAmount = $member['discount_amount'] ?? bcdiv(bcmul($membershipValue, $discountRow['discount_percentage'], 4), '100', 2); $discountAmount = $member['discount_amount'] ?? bcdiv(bcmul($membershipValue, $discountRow['discount_percentage'], 4), '100', 2);
// Discount is a negative line item $discountLabel = 'خصم خاص: ' . $discountRow['name_ar'] . ' (' . $discountRow['discount_percentage'] . '%)';
} elseif ($discType === 'fixed_amount') {
$discountAmount = $discountRow['fixed_amount'] ?? '0.00';
$discountLabel = 'خصم خاص: ' . $discountRow['name_ar'] . ' (' . money($discountAmount) . ')';
} else {
$discountAmount = '0.00';
$discountLabel = 'خصم خاص: ' . $discountRow['name_ar'];
}
if (bccomp($discountAmount, '0', 2) > 0) {
$items[] = [ $items[] = [
'type' => 'special_discount', 'type' => 'special_discount',
'label' => 'خصم خاص: ' . $discountRow['name_ar'] . ' (' . $discountRow['discount_percentage'] . '%)', 'label' => $discountLabel,
'amount' => '-' . $discountAmount, 'amount' => '-' . $discountAmount,
'paid' => false, 'paid' => false,
'included' => false, 'included' => false,
'category' => 'discount', 'category' => 'discount',
]; ];
} }
$bonusYears = (int) ($discountRow['bonus_free_subscription_years'] ?? 0);
if ($bonusYears > 0) {
$items[] = [
'type' => 'bonus_info',
'label' => 'مكافأة: ' . $bonusYears . ' سنة اشتراك مجاني',
'amount' => '0.00',
'paid' => false,
'included' => true,
'included_note' => 'تُمنح عند إتمام السداد',
'category' => 'bonus',
];
}
}
}
} }
// ── 3. Spouses ── // ── 3. Spouses ──
......
...@@ -36,31 +36,20 @@ class SpecialDiscountController extends Controller ...@@ -36,31 +36,20 @@ class SpecialDiscountController extends Controller
public function store(Request $request): Response public function store(Request $request): Response
{ {
$nameAr = trim((string) $request->post('name_ar', '')); $data = $this->extractFormData($request);
$nameEn = trim((string) $request->post('name_en', '')) ?: null;
$percentage = trim((string) $request->post('discount_percentage', '0'));
$requiresDocument = (int) $request->post('requires_document', 0);
$description = trim((string) $request->post('description', '')) ?: null;
if ($nameAr === '') { if ($data['name_ar'] === '') {
return $this->redirect('/pricing/special-discounts/create')->withError('اسم الخصم مطلوب'); return $this->redirect('/pricing/special-discounts/create')->withError('اسم الخصم مطلوب');
} }
if (bccomp($percentage, '0', 2) <= 0 || bccomp($percentage, '100', 2) > 0) {
return $this->redirect('/pricing/special-discounts/create')->withError('نسبة الخصم يجب أن تكون بين 0.01% و 100%'); $validationError = $this->validateDiscountData($data);
if ($validationError) {
return $this->redirect('/pricing/special-discounts/create')->withError($validationError);
} }
$employee = App::getInstance()->currentEmployee(); SpecialDiscount::create($this->buildCreateArray($data));
SpecialDiscount::create([ return $this->redirect('/pricing/special-discounts')->withSuccess('تم إضافة الخصم بنجاح');
'name_ar' => $nameAr,
'name_en' => $nameEn,
'discount_percentage' => $percentage,
'requires_document' => $requiresDocument,
'description' => $description,
'is_active' => 1,
]);
return $this->redirect('/pricing/special-discounts')->withSuccess('تم إضافة الخصم الخاص بنجاح');
} }
public function edit(Request $request, string $id): Response public function edit(Request $request, string $id): Response
...@@ -84,31 +73,25 @@ class SpecialDiscountController extends Controller ...@@ -84,31 +73,25 @@ class SpecialDiscountController extends Controller
return $this->redirect('/pricing/special-discounts')->withError('الخصم غير موجود'); return $this->redirect('/pricing/special-discounts')->withError('الخصم غير موجود');
} }
$nameAr = trim((string) $request->post('name_ar', '')); $data = $this->extractFormData($request);
$nameEn = trim((string) $request->post('name_en', '')) ?: null; $data['is_active'] = (int) $request->post('is_active', 1);
$percentage = trim((string) $request->post('discount_percentage', '0'));
$requiresDocument = (int) $request->post('requires_document', 0);
$description = trim((string) $request->post('description', '')) ?: null;
$isActive = (int) $request->post('is_active', 1);
if ($nameAr === '') { if ($data['name_ar'] === '') {
return $this->redirect("/pricing/special-discounts/{$id}/edit")->withError('اسم الخصم مطلوب'); return $this->redirect("/pricing/special-discounts/{$id}/edit")->withError('اسم الخصم مطلوب');
} }
if (bccomp($percentage, '0', 2) <= 0 || bccomp($percentage, '100', 2) > 0) {
return $this->redirect("/pricing/special-discounts/{$id}/edit")->withError('نسبة الخصم يجب أن تكون بين 0.01% و 100%'); $validationError = $this->validateDiscountData($data);
if ($validationError) {
return $this->redirect("/pricing/special-discounts/{$id}/edit")->withError($validationError);
} }
$db->update('special_discounts', [ $updateArray = $this->buildCreateArray($data);
'name_ar' => $nameAr, $updateArray['is_active'] = $data['is_active'];
'name_en' => $nameEn, $updateArray['updated_at'] = date('Y-m-d H:i:s');
'discount_percentage' => $percentage,
'requires_document' => $requiresDocument,
'description' => $description,
'is_active' => $isActive,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/special-discounts')->withSuccess('تم تحديث الخصم الخاص'); $db->update('special_discounts', $updateArray, '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/special-discounts')->withSuccess('تم تحديث الخصم');
} }
public function toggleActive(Request $request, string $id): Response public function toggleActive(Request $request, string $id): Response
...@@ -127,4 +110,86 @@ class SpecialDiscountController extends Controller ...@@ -127,4 +110,86 @@ class SpecialDiscountController extends Controller
return $this->redirect('/pricing/special-discounts')->withSuccess($newActive ? 'تم تفعيل الخصم' : 'تم تعطيل الخصم'); return $this->redirect('/pricing/special-discounts')->withSuccess($newActive ? 'تم تفعيل الخصم' : 'تم تعطيل الخصم');
} }
private function extractFormData(Request $request): array
{
return [
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'discount_type' => $request->post('discount_type', 'percentage'),
'discount_percentage' => trim((string) $request->post('discount_percentage', '0')),
'fixed_amount' => trim((string) $request->post('fixed_amount', '')) ?: null,
'applies_to' => $request->post('applies_to', 'membership_fee'),
'effective_from' => trim((string) $request->post('effective_from', '')) ?: null,
'effective_to' => trim((string) $request->post('effective_to', '')) ?: null,
'condition_type' => $request->post('condition_type', 'none'),
'condition_min_amount' => trim((string) $request->post('condition_min_amount', '')) ?: null,
'bonus_free_subscription_years'=> (int) $request->post('bonus_free_subscription_years', 0),
'requires_document' => (int) $request->post('requires_document', 0),
'description' => trim((string) $request->post('description', '')) ?: null,
];
}
private function validateDiscountData(array $data): ?string
{
$type = $data['discount_type'];
if ($type === 'percentage') {
$pct = $data['discount_percentage'];
if (bccomp($pct, '0', 2) <= 0 || bccomp($pct, '100', 2) > 0) {
return 'نسبة الخصم يجب أن تكون بين 0.01% و 100%';
}
} elseif ($type === 'fixed_amount') {
if (!$data['fixed_amount'] || bccomp($data['fixed_amount'], '0', 2) <= 0) {
return 'مبلغ الخصم الثابت مطلوب ويجب أن يكون أكبر من صفر';
}
}
if ($data['condition_type'] === 'min_payment') {
if (!$data['condition_min_amount'] || bccomp($data['condition_min_amount'], '0', 2) <= 0) {
return 'الحد الأدنى للسداد مطلوب عند اختيار شرط "سداد حد أدنى"';
}
}
$validTypes = ['percentage', 'fixed_amount', 'free_subscription'];
if (!in_array($type, $validTypes, true)) {
return 'نوع الخصم غير صالح';
}
$validAppliesTo = ['membership_fee', 'subscription', 'all'];
if (!in_array($data['applies_to'], $validAppliesTo, true)) {
return 'مجال التطبيق غير صالح';
}
$validConditions = ['none', 'full_payment', 'min_payment'];
if (!in_array($data['condition_type'], $validConditions, true)) {
return 'شرط التطبيق غير صالح';
}
if ($data['effective_from'] && $data['effective_to'] && $data['effective_from'] > $data['effective_to']) {
return 'تاريخ البداية يجب أن يكون قبل تاريخ الانتهاء';
}
return null;
}
private function buildCreateArray(array $data): array
{
return [
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'discount_type' => $data['discount_type'],
'discount_percentage' => $data['discount_type'] === 'percentage' ? $data['discount_percentage'] : '0.00',
'fixed_amount' => $data['discount_type'] === 'fixed_amount' ? $data['fixed_amount'] : null,
'applies_to' => $data['applies_to'],
'effective_from' => $data['effective_from'],
'effective_to' => $data['effective_to'],
'condition_type' => $data['condition_type'],
'condition_min_amount' => $data['condition_type'] === 'min_payment' ? $data['condition_min_amount'] : null,
'bonus_free_subscription_years' => $data['bonus_free_subscription_years'],
'requires_document' => $data['requires_document'],
'description' => $data['description'],
'is_active' => 1,
];
}
} }
...@@ -12,11 +12,23 @@ class SpecialDiscount extends Model ...@@ -12,11 +12,23 @@ class SpecialDiscount extends Model
protected static bool $softDelete = false; protected static bool $softDelete = false;
protected static bool $timestamps = true; protected static bool $timestamps = true;
protected static array $fillable = [ protected static array $fillable = [
'name_ar', 'name_en', 'discount_percentage', 'requires_document', 'name_ar', 'name_en', 'discount_percentage', 'discount_type',
'description', 'is_active', 'applies_to', 'effective_from', 'effective_to', 'fixed_amount',
'condition_type', 'condition_min_amount', 'bonus_free_subscription_years',
'requires_document', 'description', 'is_active',
]; ];
public static function allActive(): array public static function allActive(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
return $db->select(
"SELECT * FROM `special_discounts` WHERE `is_active` = 1 AND (effective_from IS NULL OR effective_from <= ?) AND (effective_to IS NULL OR effective_to >= ?) ORDER BY `name_ar`",
[$today, $today]
);
}
public static function allActiveUnfiltered(): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
return $db->select( return $db->select(
......
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Services;
use App\Core\App;
use App\Core\Logger;
final class SpecialDiscountService
{
/**
* Evaluate which discounts apply to a member based on their payment context.
* Called during billing to calculate applicable discount amount.
*
* @return array{discount_amount: string, discount_label: string, bonus_free_years: int, discount_id: int|null}
*/
public static function evaluateForMember(int $memberId, string $membershipValue, string $totalPaid = '0.00'): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$memberId]);
if (!$member) {
return self::emptyResult();
}
// If the member has a specific discount assigned, use that
if (!empty($member['special_discount_id'])) {
$discount = $db->selectOne(
"SELECT * FROM special_discounts WHERE id = ? AND is_active = 1",
[(int) $member['special_discount_id']]
);
if ($discount) {
if (self::isWithinDateRange($discount, $today)) {
return self::calculateDiscount($discount, $membershipValue, $totalPaid);
}
}
}
// Check for auto-applicable conditional discounts (full_payment, min_payment)
$conditionalDiscounts = $db->select(
"SELECT * FROM special_discounts WHERE is_active = 1 AND condition_type != 'none' AND (effective_from IS NULL OR effective_from <= ?) AND (effective_to IS NULL OR effective_to >= ?) ORDER BY discount_percentage DESC",
[$today, $today]
);
foreach ($conditionalDiscounts as $discount) {
if (self::conditionMet($discount, $membershipValue, $totalPaid)) {
return self::calculateDiscount($discount, $membershipValue, $totalPaid);
}
}
return self::emptyResult();
}
/**
* Check if a member qualifies for bonus free subscription years.
* Called after a membership_fee payment is completed.
*/
public static function getBonusFreeYears(int $memberId): int
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$member = $db->selectOne("SELECT special_discount_id, membership_value FROM members WHERE id = ?", [$memberId]);
if (!$member || empty($member['special_discount_id'])) {
// Check conditional discounts
$membershipValue = $member['membership_value'] ?? '0.00';
$totalPaid = self::getTotalMembershipPaid($db, $memberId);
$conditionalDiscounts = $db->select(
"SELECT * FROM special_discounts WHERE is_active = 1 AND condition_type != 'none' AND bonus_free_subscription_years > 0 AND (effective_from IS NULL OR effective_from <= ?) AND (effective_to IS NULL OR effective_to >= ?)",
[$today, $today]
);
foreach ($conditionalDiscounts as $d) {
if (self::conditionMet($d, $membershipValue, $totalPaid)) {
return (int) $d['bonus_free_subscription_years'];
}
}
return 0;
}
$discount = $db->selectOne(
"SELECT * FROM special_discounts WHERE id = ? AND is_active = 1 AND bonus_free_subscription_years > 0",
[(int) $member['special_discount_id']]
);
if (!$discount || !self::isWithinDateRange($discount, $today)) {
return 0;
}
return (int) $discount['bonus_free_subscription_years'];
}
/**
* Apply free subscription bonus: mark N years of subscriptions as paid (discount = 100%).
*/
public static function applyFreeSubscriptionBonus(int $memberId, int $freeYears): void
{
if ($freeYears <= 0) return;
$db = App::getInstance()->db();
$currentFy = financial_year();
$pendingSubs = $db->select(
"SELECT id, financial_year, total_amount FROM subscriptions WHERE member_id = ? AND status = 'pending' ORDER BY financial_year ASC LIMIT ?",
[$memberId, $freeYears * 10]
);
$yearsMarked = 0;
$lastFy = '';
foreach ($pendingSubs as $sub) {
if ($sub['financial_year'] !== $lastFy) {
$yearsMarked++;
$lastFy = $sub['financial_year'];
if ($yearsMarked > $freeYears) break;
}
$db->update('subscriptions', [
'status' => 'paid',
'discount_amount' => $sub['total_amount'],
'total_amount' => '0.00',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'receipt_number' => 'FREE-BONUS',
], '`id` = ?', [(int) $sub['id']]);
}
if ($yearsMarked > 0) {
Logger::info("SpecialDiscountService: applied {$yearsMarked} free subscription year(s) for member #{$memberId}");
}
}
private static function conditionMet(array $discount, string $membershipValue, string $totalPaid): bool
{
$conditionType = $discount['condition_type'] ?? 'none';
if ($conditionType === 'full_payment') {
return bccomp($membershipValue, '0.01', 2) >= 0 && bccomp($totalPaid, $membershipValue, 2) >= 0;
}
if ($conditionType === 'min_payment') {
$minAmount = $discount['condition_min_amount'] ?? '0.00';
return bccomp($totalPaid, $minAmount, 2) >= 0;
}
return false;
}
private static function calculateDiscount(array $discount, string $membershipValue, string $totalPaid): array
{
$type = $discount['discount_type'] ?? 'percentage';
$discountAmount = '0.00';
if ($type === 'percentage') {
$pct = $discount['discount_percentage'] ?? '0.00';
$discountAmount = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
} elseif ($type === 'fixed_amount') {
$discountAmount = $discount['fixed_amount'] ?? '0.00';
} elseif ($type === 'free_subscription') {
$discountAmount = '0.00';
}
return [
'discount_amount' => $discountAmount,
'discount_label' => $discount['name_ar'] ?? 'خصم خاص',
'discount_id' => (int) $discount['id'],
'discount_type' => $type,
'percentage' => $discount['discount_percentage'] ?? '0.00',
'bonus_free_years'=> (int) ($discount['bonus_free_subscription_years'] ?? 0),
'applies_to' => $discount['applies_to'] ?? 'membership_fee',
];
}
private static function isWithinDateRange(array $discount, string $today): bool
{
$from = $discount['effective_from'] ?? null;
$to = $discount['effective_to'] ?? null;
if ($from && $today < $from) return false;
if ($to && $today > $to) return false;
return true;
}
private static function getTotalMembershipPaid(\App\Core\Database $db, int $memberId): string
{
$row = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee', 'down_payment') AND is_voided = 0",
[$memberId]
);
return $row['total'] ?? '0.00';
}
private static function emptyResult(): array
{
return [
'discount_amount' => '0.00',
'discount_label' => '',
'discount_id' => null,
'discount_type' => null,
'percentage' => '0.00',
'bonus_free_years'=> 0,
'applies_to' => 'membership_fee',
];
}
}
...@@ -11,33 +11,119 @@ ...@@ -11,33 +11,119 @@
<form method="POST" action="<?= $discount ? '/pricing/special-discounts/' . (int) $discount['id'] : '/pricing/special-discounts' ?>"> <form method="POST" action="<?= $discount ? '/pricing/special-discounts/' . (int) $discount['id'] : '/pricing/special-discounts' ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<!-- Basic Info -->
<div class="card" style="padding:20px;margin-bottom:20px;"> <div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;">
<i data-lucide="percent" style="width:18px;height:18px;color:#0D7377;"></i> <i data-lucide="percent" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الخصم الخاص</h3> <h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الخصم</h3>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">اسم الخصم بالعربي <span style="color:#DC2626;">*</span></label> <label class="form-label">اسم الخصم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" value="<?= e($discount['name_ar'] ?? '') ?>" class="form-input" required maxlength="200" placeholder="مثال: عضو شباب ورياضة"> <input type="text" name="name_ar" value="<?= e($discount['name_ar'] ?? '') ?>" class="form-input" required maxlength="200" placeholder="مثال: خصم السداد الكامل">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">اسم الخصم بالإنجليزي</label> <label class="form-label">اسم الخصم بالإنجليزي</label>
<input type="text" name="name_en" value="<?= e($discount['name_en'] ?? '') ?>" class="form-input" maxlength="200" placeholder="e.g. Youth & Sports Member"> <input type="text" name="name_en" value="<?= e($discount['name_en'] ?? '') ?>" class="form-input" maxlength="200" placeholder="e.g. Full Payment Discount">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">نوع الخصم <span style="color:#DC2626;">*</span></label>
<select name="discount_type" id="discount_type" class="form-select" required>
<option value="percentage" <?= ($discount['discount_type'] ?? 'percentage') === 'percentage' ? 'selected' : '' ?>>نسبة مئوية (%)</option>
<option value="fixed_amount" <?= ($discount['discount_type'] ?? '') === 'fixed_amount' ? 'selected' : '' ?>>مبلغ ثابت (ج.م)</option>
<option value="free_subscription" <?= ($discount['discount_type'] ?? '') === 'free_subscription' ? 'selected' : '' ?>>اشتراك سنوي مجاني</option>
</select>
</div>
<div class="form-group" id="percentage-group">
<label class="form-label">نسبة الخصم (%) <span style="color:#DC2626;">*</span></label> <label class="form-label">نسبة الخصم (%) <span style="color:#DC2626;">*</span></label>
<input type="number" name="discount_percentage" value="<?= e($discount['discount_percentage'] ?? '') ?>" class="form-input" required min="0.01" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="مثال: 15.00"> <input type="number" name="discount_percentage" id="discount_percentage" value="<?= e($discount['discount_percentage'] ?? '') ?>" class="form-input" min="0.01" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="مثال: 10.00">
<small style="color:#6B7280;font-size:11px;">نسبة الخصم من قيمة العضوية</small> <small style="color:#6B7280;font-size:11px;">نسبة الخصم من المبلغ المستهدف</small>
</div>
<div class="form-group" id="fixed-amount-group" style="display:none;">
<label class="form-label">مبلغ الخصم الثابت (ج.م) <span style="color:#DC2626;">*</span></label>
<input type="number" name="fixed_amount" id="fixed_amount" value="<?= e($discount['fixed_amount'] ?? '') ?>" class="form-input" min="1" step="0.01" style="direction:ltr;text-align:left;" placeholder="مثال: 5000.00">
</div>
<div class="form-group">
<label class="form-label">يُطبَّق على</label>
<select name="applies_to" class="form-select">
<option value="membership_fee" <?= ($discount['applies_to'] ?? 'membership_fee') === 'membership_fee' ? 'selected' : '' ?>>قيمة العضوية</option>
<option value="subscription" <?= ($discount['applies_to'] ?? '') === 'subscription' ? 'selected' : '' ?>>الاشتراك السنوي</option>
<option value="all" <?= ($discount['applies_to'] ?? '') === 'all' ? 'selected' : '' ?>>الكل (عضوية + اشتراك)</option>
</select>
</div>
</div>
</div>
<!-- Date Range -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;">
<i data-lucide="calendar-range" style="width:18px;height:18px;color:#7C3AED;"></i>
<h3 style="margin:0;color:#7C3AED;font-size:15px;">فترة السريان</h3>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ البداية</label>
<input type="date" name="effective_from" value="<?= e($discount['effective_from'] ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;">
<small style="color:#6B7280;font-size:11px;">اتركه فارغاً ليكون ساري من الآن</small>
</div>
<div class="form-group">
<label class="form-label">تاريخ الانتهاء</label>
<input type="date" name="effective_to" value="<?= e($discount['effective_to'] ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;">
<small style="color:#6B7280;font-size:11px;">اتركه فارغاً ليكون ساري بدون حد</small>
</div>
</div>
</div>
<!-- Conditions -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;">
<i data-lucide="filter" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">شروط التطبيق</h3>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">شرط التطبيق</label>
<select name="condition_type" id="condition_type" class="form-select">
<option value="none" <?= ($discount['condition_type'] ?? 'none') === 'none' ? 'selected' : '' ?>>بدون شرط — خصم مباشر</option>
<option value="full_payment" <?= ($discount['condition_type'] ?? '') === 'full_payment' ? 'selected' : '' ?>>سداد كامل قيمة العضوية نقداً</option>
<option value="min_payment" <?= ($discount['condition_type'] ?? '') === 'min_payment' ? 'selected' : '' ?>>سداد حد أدنى محدد</option>
</select>
<small style="color:#6B7280;font-size:11px;">الشرط الذي يجب تحقيقه ليتم تفعيل الخصم تلقائياً</small>
</div>
<div class="form-group" id="min-amount-group" style="display:none;">
<label class="form-label">الحد الأدنى للسداد (ج.م) <span style="color:#DC2626;">*</span></label>
<input type="number" name="condition_min_amount" id="condition_min_amount" value="<?= e($discount['condition_min_amount'] ?? '') ?>" class="form-input" min="1" step="0.01" style="direction:ltr;text-align:left;" placeholder="مثال: 150000.00">
<small style="color:#6B7280;font-size:11px;">المبلغ الذي يجب سداده كاملاً لتفعيل الخصم</small>
</div>
</div>
</div>
<!-- Bonus -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;">
<i data-lucide="gift" style="width:18px;height:18px;color:#059669;"></i>
<h3 style="margin:0;color:#059669;font-size:15px;">مكافآت إضافية (اختياري)</h3>
</div>
<p style="font-size:13px;color:#6B7280;margin-bottom:15px;">يمكنك ربط مكافأة إضافية بالخصم — تُمنح تلقائياً عند تفعيل الخصم</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">اشتراكات سنوية مجانية</label>
<input type="number" name="bonus_free_subscription_years" value="<?= e($discount['bonus_free_subscription_years'] ?? '0') ?>" class="form-input" min="0" max="5" style="direction:ltr;text-align:left;">
<small style="color:#6B7280;font-size:11px;">عدد السنوات المجانية (0 = بدون مكافأة). مثال: 1 = الاشتراك السنوي القادم مجاني</small>
</div>
</div>
</div>
<!-- Other -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">يحتاج إثبات مستند؟</label> <label class="form-label">يحتاج إثبات مستند؟</label>
<select name="requires_document" class="form-select"> <select name="requires_document" class="form-select">
<option value="0" <?= empty($discount['requires_document']) ? 'selected' : '' ?>>لا — بدون إثبات</option> <option value="0" <?= empty($discount['requires_document']) ? 'selected' : '' ?>>لا — بدون إثبات</option>
<option value="1" <?= !empty($discount['requires_document']) ? 'selected' : '' ?>>نعم — يجب رفع مستند</option> <option value="1" <?= !empty($discount['requires_document']) ? 'selected' : '' ?>>نعم — يجب رفع مستند</option>
</select> </select>
<small style="color:#6B7280;font-size:11px;">إذا اختير "نعم"، سيُطلب رفع مستند إثبات عند تطبيق الخصم على العضو</small>
</div> </div>
<?php if ($discount): ?> <?php if ($discount): ?>
<div class="form-group"> <div class="form-group">
...@@ -50,7 +136,7 @@ ...@@ -50,7 +136,7 @@
<?php endif; ?> <?php endif; ?>
<div class="form-group" style="grid-column:1/-1;"> <div class="form-group" style="grid-column:1/-1;">
<label class="form-label">وصف / ملاحظات</label> <label class="form-label">وصف / ملاحظات</label>
<textarea name="description" class="form-textarea" rows="3" placeholder="وصف اختياري لنوع الخصم..."><?= e($discount['description'] ?? '') ?></textarea> <textarea name="description" class="form-textarea" rows="3" placeholder="وصف اختياري لنوع الخصم وشروطه..."><?= e($discount['description'] ?? '') ?></textarea>
</div> </div>
</div> </div>
</div> </div>
...@@ -62,5 +148,40 @@ ...@@ -62,5 +148,40 @@
<a href="/pricing/special-discounts" class="btn btn-outline">إلغاء</a> <a href="/pricing/special-discounts" class="btn btn-outline">إلغاء</a>
</form> </form>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script> <script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
var discountType = document.getElementById('discount_type');
var conditionType = document.getElementById('condition_type');
var percentageGroup = document.getElementById('percentage-group');
var fixedAmountGroup = document.getElementById('fixed-amount-group');
var minAmountGroup = document.getElementById('min-amount-group');
function updateVisibility() {
var dt = discountType.value;
percentageGroup.style.display = (dt === 'percentage') ? '' : 'none';
fixedAmountGroup.style.display = (dt === 'fixed_amount') ? '' : 'none';
if (dt === 'percentage') {
document.getElementById('discount_percentage').required = true;
document.getElementById('fixed_amount').required = false;
} else if (dt === 'fixed_amount') {
document.getElementById('discount_percentage').required = false;
document.getElementById('fixed_amount').required = true;
} else {
document.getElementById('discount_percentage').required = false;
document.getElementById('fixed_amount').required = false;
}
var ct = conditionType.value;
minAmountGroup.style.display = (ct === 'min_payment') ? '' : 'none';
document.getElementById('condition_min_amount').required = (ct === 'min_payment');
}
discountType.addEventListener('change', updateVisibility);
conditionType.addEventListener('change', updateVisibility);
updateVisibility();
});
</script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -40,10 +40,13 @@ ...@@ -40,10 +40,13 @@
<tr> <tr>
<th>#</th> <th>#</th>
<th>اسم الخصم</th> <th>اسم الخصم</th>
<th>النسبة</th> <th>النوع</th>
<th>يحتاج إثبات</th> <th>القيمة</th>
<th>يُطبَّق على</th>
<th>الشرط</th>
<th>المكافأة</th>
<th>الفترة</th>
<th>الحالة</th> <th>الحالة</th>
<th>الوصف</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
...@@ -52,16 +55,62 @@ ...@@ -52,16 +55,62 @@
<tr> <tr>
<td><?= (int) $row['id'] ?></td> <td><?= (int) $row['id'] ?></td>
<td style="font-weight:600;"><?= e($row['name_ar']) ?></td> <td style="font-weight:600;"><?= e($row['name_ar']) ?></td>
<td style="font-weight:700;color:#0D7377;"><?= e($row['discount_percentage']) ?>%</td> <td style="font-size:12px;">
<td> <?= match($row['discount_type'] ?? 'percentage') {
<?php if ($row['requires_document']): ?> 'percentage' => 'نسبة مئوية',
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#FFF7ED;color:#D97706;"> 'fixed_amount' => 'مبلغ ثابت',
<i data-lucide="file-check" style="width:12px;height:12px;vertical-align:middle;margin-left:3px;"></i> مطلوب 'free_subscription' => 'اشتراك مجاني',
</span> default => $row['discount_type'] ?? '—'
} ?>
</td>
<td style="font-weight:700;color:#0D7377;">
<?php if (($row['discount_type'] ?? 'percentage') === 'percentage'): ?>
<?= e($row['discount_percentage']) ?>%
<?php elseif (($row['discount_type'] ?? '') === 'fixed_amount'): ?>
<?= money($row['fixed_amount'] ?? '0') ?>
<?php else: ?>
<?php endif; ?>
</td>
<td style="font-size:12px;">
<?= match($row['applies_to'] ?? 'membership_fee') {
'membership_fee' => 'العضوية',
'subscription' => 'الاشتراك',
'all' => 'الكل',
default => '—'
} ?>
</td>
<td style="font-size:12px;">
<?= match($row['condition_type'] ?? 'none') {
'none' => '<span style="color:#9CA3AF;">بدون شرط</span>',
'full_payment' => 'سداد كامل',
'min_payment' => 'حد أدنى ' . money($row['condition_min_amount'] ?? '0'),
default => '—'
} ?>
</td>
<td style="font-size:12px;">
<?php $bonus = (int) ($row['bonus_free_subscription_years'] ?? 0); ?>
<?php if ($bonus > 0): ?>
<span style="color:#059669;font-weight:600;"><?= $bonus ?> سنة مجانية</span>
<?php else: ?> <?php else: ?>
<span style="color:#9CA3AF;font-size:12px;">غير مطلوب</span> <span style="color:#9CA3AF;"></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="font-size:11px;color:#6B7280;">
<?php
$from = $row['effective_from'] ?? null;
$to = $row['effective_to'] ?? null;
if ($from && $to) {
echo e($from) . '<br>' . e($to);
} elseif ($from) {
echo 'من ' . e($from);
} elseif ($to) {
echo 'حتى ' . e($to);
} else {
echo '<span style="color:#9CA3AF;">دائم</span>';
}
?>
</td>
<td> <td>
<?php if ($row['is_active']): ?> <?php if ($row['is_active']): ?>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#ECFDF5;color:#059669;">فعال</span> <span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#ECFDF5;color:#059669;">فعال</span>
...@@ -69,7 +118,6 @@ ...@@ -69,7 +118,6 @@
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#F3F4F6;color:#6B7280;">معطل</span> <span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#F3F4F6;color:#6B7280;">معطل</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="font-size:12px;color:#6B7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= e($row['description'] ?? '—') ?></td>
<td style="white-space:nowrap;"> <td style="white-space:nowrap;">
<a href="/pricing/special-discounts/<?= (int) $row['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;"> <a href="/pricing/special-discounts/<?= (int) $row['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit" style="width:12px;height:12px;vertical-align:middle;"></i> تعديل <i data-lucide="edit" style="width:12px;height:12px;vertical-align:middle;"></i> تعديل
...@@ -101,7 +149,7 @@ ...@@ -101,7 +149,7 @@
<div style="padding:60px 20px;text-align:center;"> <div style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;"><i data-lucide="percent" style="width:48px;height:48px;color:#D1D5DB;"></i></div> <div style="margin-bottom:15px;"><i data-lucide="percent" style="width:48px;height:48px;color:#D1D5DB;"></i></div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد خصومات خاصة</h3> <h3 style="color:#6B7280;margin:0 0 8px;">لا توجد خصومات خاصة</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 15px;">أضف أنواع الخصومات الخاصة (مثل: عضو شباب ورياضة) لتطبيقها على الأعضاء.</p> <p style="color:#9CA3AF;font-size:14px;margin:0 0 15px;">أضف أنواع الخصومات الخاصة لتطبيقها على الأعضاء تلقائياً أو يدوياً.</p>
<a href="/pricing/special-discounts/create" class="btn btn-primary">إضافة خصم جديد</a> <a href="/pricing/special-discounts/create" class="btn btn-primary">إضافة خصم جديد</a>
</div> </div>
<?php endif; ?> <?php endif; ?>
......
...@@ -3,6 +3,7 @@ declare(strict_types=1); ...@@ -3,6 +3,7 @@ declare(strict_types=1);
use App\Core\Registries\MenuRegistry; use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry; use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
MenuRegistry::register('pricing', [ MenuRegistry::register('pricing', [
'label_ar' => 'التسعير', 'label_ar' => 'التسعير',
...@@ -25,3 +26,18 @@ PermissionRegistry::register('pricing', [ ...@@ -25,3 +26,18 @@ PermissionRegistry::register('pricing', [
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'], 'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'], 'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'],
]); ]);
// When a member is activated, check if their discount includes free subscription bonus
EventBus::listen('member.activated', function (array $data) {
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$freeYears = \App\Modules\Pricing\Services\SpecialDiscountService::getBonusFreeYears($memberId);
if ($freeYears > 0) {
\App\Modules\Pricing\Services\SpecialDiscountService::applyFreeSubscriptionBonus($memberId, $freeYears);
}
} catch (\Throwable $e) {
\App\Core\Logger::error("pricing: member.activated bonus check failed: " . $e->getMessage(), ['data' => $data]);
}
}, 50);
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$cols = [
'discount_type' => "ALTER TABLE special_discounts ADD COLUMN discount_type ENUM('percentage','fixed_amount','free_subscription') NOT NULL DEFAULT 'percentage' AFTER discount_percentage",
'applies_to' => "ALTER TABLE special_discounts ADD COLUMN applies_to ENUM('membership_fee','subscription','all') NOT NULL DEFAULT 'membership_fee' AFTER discount_type",
'effective_from' => "ALTER TABLE special_discounts ADD COLUMN effective_from DATE NULL DEFAULT NULL AFTER applies_to",
'effective_to' => "ALTER TABLE special_discounts ADD COLUMN effective_to DATE NULL DEFAULT NULL AFTER effective_from",
'fixed_amount' => "ALTER TABLE special_discounts ADD COLUMN fixed_amount DECIMAL(15,2) NULL DEFAULT NULL AFTER effective_to",
'condition_type' => "ALTER TABLE special_discounts ADD COLUMN condition_type ENUM('none','min_payment','full_payment') NOT NULL DEFAULT 'none' AFTER fixed_amount",
'condition_min_amount' => "ALTER TABLE special_discounts ADD COLUMN condition_min_amount DECIMAL(15,2) NULL DEFAULT NULL AFTER condition_type",
'bonus_free_subscription_years' => "ALTER TABLE special_discounts ADD COLUMN bonus_free_subscription_years INT UNSIGNED NOT NULL DEFAULT 0 AFTER condition_min_amount",
];
foreach ($cols as $col => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'special_discounts' AND column_name = ?",
[$col]
);
if (!$exists) {
$db->raw($sql);
}
}
};
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