Commit 570cb709 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(pricing): require board decision for member special discounts, allow...

fix(pricing): require board decision for member special discounts, allow fixed-amount down payment on board offers

- special_discounts gains board_decision_number/board_decision_date; the
  member-facing "special discount" dropdown (fill-form, edit, show, apply)
  now only lists/accepts discounts backed by a board decision instead of
  the full unaudited special_discounts catalog, per client request that the
  dropdown should only read board-approved discounts.
- board_offers gains inst_down_payment_type (percentage/fixed_amount) so a
  board-approved offer's installment down payment (المقدم) can be a fixed
  cash amount, not only a percentage. Wired through BoardOfferService,
  InstallmentCalculator (new min_down_amount override) and
  PaymentLifecycleService so the fixed amount is actually enforced at
  billing time, not just in the admin form.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent d39d9293
......@@ -26,10 +26,12 @@ final class InstallmentCalculator
if (empty($offerOverrides)) {
$offerOverrides = BoardOfferService::getInstallmentOverrides();
}
$minDownAmount = null;
if (!empty($offerOverrides)) {
if (isset($offerOverrides['max_months'])) $maxMonths = $offerOverrides['max_months'];
if (isset($offerOverrides['interest_rate'])) $annualRate = $offerOverrides['interest_rate'];
if (isset($offerOverrides['min_down_pct'])) $minDownPct = $offerOverrides['min_down_pct'];
if (isset($offerOverrides['min_down_amount'])) $minDownAmount = $offerOverrides['min_down_amount'];
}
$graceType = $offerOverrides['grace_type'] ?? null;
......@@ -45,10 +47,17 @@ final class InstallmentCalculator
$errors = [];
if ($minDownAmount !== null) {
$minDown = $minDownAmount;
if (bccomp($downPayment, $minDown, 2) < 0) {
$errors[] = "الحد الأدنى للمقدم " . number_format((float) $minDown, 2) . ' ج.م';
}
} else {
$minDown = bcdiv(bcmul($totalAmount, $minDownPct, 4), '100', 2);
if (bccomp($downPayment, $minDown, 2) < 0) {
$errors[] = "الحد الأدنى للمقدم {$minDownPct}% = " . number_format((float) $minDown, 2) . ' ج.م';
}
}
if (bccomp($downPayment, $totalAmount, 2) >= 0) {
$errors[] = 'المقدم لا يمكن أن يكون أكبر من أو يساوي المبلغ الكلي';
......
......@@ -405,7 +405,7 @@ class MemberController extends Controller
'formFee' => MemberNumberGenerator::getFormFee(),
'formFilled' => $formFilled,
'specialDiscount' => $specialDiscount,
'availableDiscounts' => SpecialDiscount::allActive(),
'availableDiscounts' => SpecialDiscount::allActiveBoardApproved(),
'pendingFormFee' => $pendingFormFee,
'pendingMembership' => $pendingMembership,
'pendingAdditions' => $pendingAdditions,
......@@ -480,7 +480,7 @@ class MemberController extends Controller
return $this->redirect("/members/{$id}")->withSuccess('تم إزالة الخصم');
}
$discount = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1", [$discountId]);
$discount = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1 AND board_decision_number IS NOT NULL AND board_decision_number != ''", [$discountId]);
if (!$discount) {
return $this->redirect("/members/{$id}")->withError('الخصم غير موجود');
}
......@@ -704,7 +704,7 @@ class MemberController extends Controller
'qualifications' => $db->select("SELECT id, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order"),
'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1 ORDER BY name_ar"),
'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"),
'specialDiscounts' => SpecialDiscount::allActive(),
'specialDiscounts' => SpecialDiscount::allActiveBoardApproved(),
'salesReps' => $salesReps,
'schemaHtml' => '',
]);
......@@ -738,7 +738,7 @@ class MemberController extends Controller
// Special discount
$discountId = trim((string) ($data['special_discount_id'] ?? ''));
if ($discountId !== '' && $discountId !== '0') {
$discountRow = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1", [(int) $discountId]);
$discountRow = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1 AND board_decision_number IS NOT NULL AND board_decision_number != ''", [(int) $discountId]);
if ($discountRow) {
$update['special_discount_id'] = (int) $discountId;
$mValue = $update['membership_value'] ?? ($member->membership_value ?? '0.00');
......@@ -846,7 +846,7 @@ class MemberController extends Controller
'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1"),
'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"),
'isSuperAdmin' => self::isSuperAdmin(),
'specialDiscounts' => SpecialDiscount::allActive(),
'specialDiscounts' => SpecialDiscount::allActiveBoardApproved(),
'regulatoryDiscounts' => $db->select("SELECT * FROM regulatory_discounts WHERE is_active = 1 ORDER BY article_number"),
'salesReps' => $salesReps,
]);
......@@ -947,7 +947,7 @@ class MemberController extends Controller
$update['special_discount_id'] = null;
$update['discount_amount'] = null;
} else {
$discountRow = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1", [(int) $discountId]);
$discountRow = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1 AND board_decision_number IS NOT NULL AND board_decision_number != ''", [(int) $discountId]);
if ($discountRow) {
$update['special_discount_id'] = (int) $discountId;
$membershipValue = $member->membership_value ?? '0.00';
......
......@@ -189,6 +189,9 @@ final class BoardOfferService
if ($offer['inst_down_payment_pct'] !== null) {
$terms['down_pct'] = (string) $offer['inst_down_payment_pct'];
}
if (($offer['inst_down_payment_fixed'] ?? null) !== null) {
$terms['down_fixed'] = (string) $offer['inst_down_payment_fixed'];
}
if ($offer['inst_months'] !== null) {
$terms['max_months'] = (int) $offer['inst_months'];
}
......@@ -223,6 +226,9 @@ final class BoardOfferService
if ($offer['inst_down_payment_pct'] !== null) {
$overrides['min_down_pct'] = (string) $offer['inst_down_payment_pct'];
}
if (($offer['inst_down_payment_fixed'] ?? null) !== null) {
$overrides['min_down_amount'] = (string) $offer['inst_down_payment_fixed'];
}
if ($offer['inst_grace_type'] !== null) {
$overrides['grace_type'] = $offer['inst_grace_type'];
$overrides['grace_months'] = (int) $offer['inst_grace_months'];
......@@ -243,6 +249,7 @@ final class BoardOfferService
'cash_discount_type' => $offer['cash_discount_type'],
'cash_discount_value' => $offer['cash_discount_value'],
'inst_down_payment_pct' => $offer['inst_down_payment_pct'],
'inst_down_payment_fixed' => $offer['inst_down_payment_fixed'] ?? null,
'inst_months' => $offer['inst_months'],
'inst_interest_rate' => $offer['inst_interest_rate'],
'inst_grace_type' => $offer['inst_grace_type'],
......
......@@ -677,10 +677,14 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
<?php
$instRate = (float) ($installmentTerms['interest_rate'] ?? $installInterestRate ?? 22);
$instMaxMos = (int) ($installmentTerms['max_months'] ?? $installMaxMonths ?? 30);
$instDownFixed = $installmentTerms['down_fixed'] ?? null;
$instDownPct = (float) ($installmentTerms['down_pct'] ?? 25);
$instGraceType = $installmentTerms['grace_type'] ?? null;
$instGraceMonths = (int) ($installmentTerms['grace_months'] ?? 0);
$minDown = bcdiv(bcmul($bill['total_pending'], (string) $instDownPct, 2), '100', 2);
$minDown = $instDownFixed !== null
? (string) $instDownFixed
: bcdiv(bcmul($bill['total_pending'], (string) $instDownPct, 2), '100', 2);
$instDownLabel = $instDownFixed !== null ? money($instDownFixed) : ($instDownPct . '%');
$instRem = bcsub($bill['total_pending'], $minDown, 2);
$instInterest = bcmul(bcmul($instRem, bcdiv((string) $instRate, '100', 8), 8), bcdiv((string) $instMaxMos, '12', 8), 2);
if ($instGraceType === 'full_free_under_n' && $instMaxMos <= $instGraceMonths) {
......@@ -691,7 +695,7 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
?>
<div style="background:#fff;border:2px solid #0284C7;border-radius:12px;padding:20px;">
<h5 style="margin:0 0 4px;color:#0284C7;">&#x1f4c5; تقسيط</h5>
<p style="font-size:12px;color:#6B7280;margin:0 0 2px;">مقدم <?= $instDownPct ?>% + باقي على أقساط</p>
<p style="font-size:12px;color:#6B7280;margin:0 0 2px;">مقدم <?= e($instDownLabel) ?> + باقي على أقساط</p>
<p style="font-size:11px;color:#D97706;margin:0 0 12px;">فائدة <?= $instRate ?>% سنوياً — حتى <?= $instMaxMos ?> شهر</p>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:12px;">
<table style="width:100%;border-collapse:collapse;">
......
......@@ -230,6 +230,7 @@ final class PaymentLifecycleService
if ($offerSnapshot['inst_months'] !== null) $offerOverrides['max_months'] = (int) $offerSnapshot['inst_months'];
if ($offerSnapshot['inst_interest_rate'] !== null) $offerOverrides['interest_rate'] = (string) $offerSnapshot['inst_interest_rate'];
if ($offerSnapshot['inst_down_payment_pct'] !== null) $offerOverrides['min_down_pct'] = (string) $offerSnapshot['inst_down_payment_pct'];
if (($offerSnapshot['inst_down_payment_fixed'] ?? null) !== null) $offerOverrides['min_down_amount'] = (string) $offerSnapshot['inst_down_payment_fixed'];
if ($offerSnapshot['inst_grace_type'] !== null) {
$offerOverrides['grace_type'] = $offerSnapshot['inst_grace_type'];
$offerOverrides['grace_months'] = (int) $offerSnapshot['inst_grace_months'];
......
......@@ -125,7 +125,11 @@ class BoardOfferController extends Controller
'description' => trim((string) $request->post('description', '')) ?: null,
'cash_discount_type' => $hasCash ? ($request->post('cash_discount_type', null) ?: null) : null,
'cash_discount_value' => $hasCash ? (trim((string) $request->post('cash_discount_value', '')) ?: null) : null,
'inst_down_payment_pct' => $hasInst ? (trim((string) $request->post('inst_down_payment_pct', '')) ?: null) : null,
'inst_down_payment_type' => $hasInst ? ($request->post('inst_down_payment_type', 'percentage') ?: 'percentage') : null,
'inst_down_payment_pct' => ($hasInst && $request->post('inst_down_payment_type', 'percentage') === 'percentage')
? (trim((string) $request->post('inst_down_payment_pct', '')) ?: null) : null,
'inst_down_payment_fixed' => ($hasInst && $request->post('inst_down_payment_type', 'percentage') === 'fixed_amount')
? (trim((string) $request->post('inst_down_payment_fixed', '')) ?: null) : null,
'inst_months' => $hasInst ? ((int) $request->post('inst_months', 0) ?: null) : null,
'inst_interest_rate' => $hasInst ? (trim((string) $request->post('inst_interest_rate', '')) !== '' ? trim((string) $request->post('inst_interest_rate', '')) : null) : null,
'inst_grace_type' => $hasInst ? ($request->post('inst_grace_type', '') ?: null) : null,
......@@ -157,7 +161,7 @@ class BoardOfferController extends Controller
}
$hasCash = ($data['cash_discount_type'] !== null);
$hasInst = ($data['inst_months'] !== null || $data['inst_interest_rate'] !== null || $data['inst_down_payment_pct'] !== null || $data['inst_grace_type'] !== null);
$hasInst = ($data['inst_months'] !== null || $data['inst_interest_rate'] !== null || $data['inst_down_payment_pct'] !== null || $data['inst_down_payment_fixed'] !== null || $data['inst_grace_type'] !== null);
if (!$hasCash && !$hasInst) {
return 'يجب تحديد مسار واحد على الأقل (كاش أو تقسيط)';
......@@ -177,12 +181,21 @@ class BoardOfferController extends Controller
}
if ($hasInst) {
if ($data['inst_down_payment_type'] !== null && !in_array($data['inst_down_payment_type'], ['percentage', 'fixed_amount'], true)) {
return 'نوع المقدم غير صالح';
}
if ($data['inst_down_payment_pct'] !== null) {
$dp = (float) $data['inst_down_payment_pct'];
if ($dp < 0 || $dp > 100) {
return 'نسبة المقدم يجب أن تكون بين 0 و 100';
}
}
if ($data['inst_down_payment_fixed'] !== null) {
$dpFixed = (float) $data['inst_down_payment_fixed'];
if ($dpFixed <= 0) {
return 'مبلغ المقدم الثابت يجب أن يكون أكبر من صفر';
}
}
if ($data['inst_interest_rate'] !== null) {
$ir = (float) $data['inst_interest_rate'];
if ($ir < 0 || $ir > 100) {
......
......@@ -127,6 +127,8 @@ class SpecialDiscountController extends Controller
'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,
'board_decision_number' => trim((string) $request->post('board_decision_number', '')) ?: null,
'board_decision_date' => trim((string) $request->post('board_decision_date', '')) ?: null,
];
}
......@@ -189,6 +191,8 @@ class SpecialDiscountController extends Controller
'bonus_free_subscription_years' => $data['bonus_free_subscription_years'],
'requires_document' => $data['requires_document'],
'description' => $data['description'],
'board_decision_number' => $data['board_decision_number'],
'board_decision_date' => $data['board_decision_date'],
'is_active' => 1,
];
}
......
......@@ -14,7 +14,8 @@ class BoardOffer extends Model
protected static array $fillable = [
'title_ar', 'title_en', 'description',
'cash_discount_type', 'cash_discount_value',
'inst_down_payment_pct', 'inst_months', 'inst_interest_rate',
'inst_down_payment_pct', 'inst_down_payment_type', 'inst_down_payment_fixed',
'inst_months', 'inst_interest_rate',
'inst_grace_type', 'inst_grace_months', 'inst_post_grace_rate',
'branch_id', 'applies_to', 'effective_from', 'effective_to',
'is_active', 'board_decision_number', 'board_decision_date', 'notes',
......
......@@ -16,6 +16,7 @@ class SpecialDiscount extends Model
'applies_to', 'effective_from', 'effective_to', 'fixed_amount',
'condition_type', 'condition_min_amount', 'bonus_free_subscription_years',
'requires_document', 'description', 'is_active',
'board_decision_number', 'board_decision_date',
];
public static function allActive(): array
......@@ -28,6 +29,19 @@ class SpecialDiscount extends Model
);
}
/**
* Discounts selectable for a member's "special discount" — must be backed by a board decision.
*/
public static function allActiveBoardApproved(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
return $db->select(
"SELECT * FROM `special_discounts` WHERE `is_active` = 1 AND `board_decision_number` IS NOT NULL AND `board_decision_number` != '' 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();
......
......@@ -12,7 +12,7 @@
$old = $_SESSION['_old_input'] ?? [];
$v = fn(string $key, $default = '') => e($old[$key] ?? $offer[$key] ?? $default);
$hasCashPath = !empty($old['has_cash_path']) || (!empty($offer['cash_discount_type']));
$hasInstPath = !empty($old['has_inst_path']) || ($offer && ($offer['inst_months'] || $offer['inst_interest_rate'] !== null || $offer['inst_down_payment_pct'] !== null || $offer['inst_grace_type']));
$hasInstPath = !empty($old['has_inst_path']) || ($offer && ($offer['inst_months'] || $offer['inst_interest_rate'] !== null || $offer['inst_down_payment_pct'] !== null || $offer['inst_down_payment_fixed'] !== null || $offer['inst_grace_type']));
?>
<form method="POST" action="<?= $offer ? '/pricing/board-offers/' . (int) $offer['id'] : '/pricing/board-offers' ?>">
......@@ -128,11 +128,19 @@ $hasInstPath = !empty($old['has_inst_path']) || ($offer && ($offer['inst_months'
<p style="font-size:12px;color:#9CA3AF;margin:0 0 12px;">يتجاوز الإعدادات الافتراضية (مقدم 25%، فائدة 22%، 30 شهر)</p>
<div id="inst_section" style="<?= $hasInstPath ? '' : 'display:none;' ?>">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
<div class="form-group">
<label class="form-label">نوع المقدم</label>
<select name="inst_down_payment_type" class="form-select" id="dp_type" onchange="toggleDpType()">
<option value="percentage" <?= $v('inst_down_payment_type', 'percentage') === 'percentage' ? 'selected' : '' ?>>نسبة مئوية (%)</option>
<option value="fixed_amount" <?= $v('inst_down_payment_type') === 'fixed_amount' ? 'selected' : '' ?>>مبلغ ثابت (ج.م)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">نسبة المقدم (%)</label>
<input type="number" name="inst_down_payment_pct" value="<?= $v('inst_down_payment_pct') ?>" class="form-input" min="0" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="25">
<small style="color:#9CA3AF;font-size:11px;">فارغ = 25% (الافتراضي)</small>
<label class="form-label" id="dp_value_label">نسبة المقدم (%)</label>
<input type="number" name="inst_down_payment_pct" id="dp_pct" value="<?= $v('inst_down_payment_pct') ?>" class="form-input" min="0" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="25">
<input type="number" name="inst_down_payment_fixed" id="dp_fixed" value="<?= $v('inst_down_payment_fixed') ?>" class="form-input" min="0.01" step="0.01" style="direction:ltr;text-align:left;display:none;" placeholder="50000">
<small style="color:#9CA3AF;font-size:11px;" id="dp_hint">فارغ = 25% (الافتراضي)</small>
</div>
<div class="form-group">
<label class="form-label">عدد الأشهر</label>
......@@ -255,6 +263,15 @@ function toggleSection(id, show) {
document.getElementById(id).style.display = show ? '' : 'none';
updatePreview();
}
function toggleDpType() {
var type = document.getElementById('dp_type').value;
var isPct = (type === 'percentage');
document.getElementById('dp_pct').style.display = isPct ? '' : 'none';
document.getElementById('dp_fixed').style.display = isPct ? 'none' : '';
document.getElementById('dp_value_label').textContent = isPct ? 'نسبة المقدم (%)' : 'مبلغ المقدم الثابت (ج.م)';
document.getElementById('dp_hint').textContent = isPct ? 'فارغ = 25% (الافتراضي)' : 'مبلغ مقدم ثابت بغض النظر عن قيمة العضوية';
updatePreview();
}
function toggleGraceDetails() {
var val = document.getElementById('grace_type_select').value;
document.getElementById('grace_months_group').style.display = val ? '' : 'none';
......@@ -303,13 +320,16 @@ function updatePreview() {
}
if (hasInst) {
var dp = document.querySelector('[name=inst_down_payment_pct]').value || '25';
var dpType = document.getElementById('dp_type').value;
var dpLabel = dpType === 'percentage'
? ((document.getElementById('dp_pct').value || '25') + '%')
: ((document.getElementById('dp_fixed').value || '0') + ' ج.م');
var mo = document.querySelector('[name=inst_months]').value || '30';
var ir = document.querySelector('[name=inst_interest_rate]').value || '22';
var gt = document.getElementById('grace_type_select').value;
var gm = document.querySelector('[name=inst_grace_months]').value || '0';
var lines = 'مقدم ' + dp + '% — ' + mo + ' شهر — فائدة ' + ir + '%';
var lines = 'مقدم ' + dpLabel + ' — ' + mo + ' شهر — فائدة ' + ir + '%';
if (gt === 'first_n_months' && parseInt(gm) > 0) {
lines += '\nأول ' + gm + ' شهر بدون فوائد';
} else if (gt === 'full_free_under_n' && parseInt(gm) > 0) {
......@@ -320,6 +340,7 @@ function updatePreview() {
}
document.addEventListener('DOMContentLoaded', function() {
toggleDpType();
updatePreview();
document.querySelectorAll('input, select, textarea').forEach(function(el) {
......
......@@ -95,7 +95,7 @@ foreach ($rows as $r) {
$isFuture = $row['effective_from'] > $today;
$isActive = $row['is_active'] && !$isExpired && !$isFuture;
$hasCash = !empty($row['cash_discount_type']) && !empty($row['cash_discount_value']);
$hasInst = ($row['inst_months'] || $row['inst_interest_rate'] !== null || $row['inst_down_payment_pct'] !== null);
$hasInst = ($row['inst_months'] || $row['inst_interest_rate'] !== null || $row['inst_down_payment_pct'] !== null || $row['inst_down_payment_fixed'] !== null);
$usageCount = (int) ($row['usage_count'] ?? 0);
if ($isActive) {
......@@ -153,6 +153,8 @@ foreach ($rows as $r) {
<div style="display:flex;gap:8px;flex-wrap:wrap;font-size:12px;">
<?php if ($row['inst_down_payment_pct'] !== null): ?>
<span style="background:#fff;padding:2px 6px;border-radius:4px;border:1px solid #BFDBFE;">مقدم <?= e($row['inst_down_payment_pct']) ?>%</span>
<?php elseif ($row['inst_down_payment_fixed'] !== null): ?>
<span style="background:#fff;padding:2px 6px;border-radius:4px;border:1px solid #BFDBFE;">مقدم <?= money($row['inst_down_payment_fixed']) ?></span>
<?php endif; ?>
<?php if ($row['inst_months']): ?>
<span style="background:#fff;padding:2px 6px;border-radius:4px;border:1px solid #BFDBFE;"><?= (int) $row['inst_months'] ?> شهر</span>
......
......@@ -55,6 +55,25 @@
</div>
</div>
<!-- Board Decision -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<i data-lucide="gavel" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">قرار مجلس الإدارة</h3>
</div>
<p style="font-size:12px;color:#9CA3AF;margin:0 0 15px;">لا يظهر الخصم في قائمة اختيار العضو إلا إذا كان له رقم قرار مجلس إدارة</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">رقم قرار مجلس الإدارة</label>
<input type="text" name="board_decision_number" value="<?= e($discount['board_decision_number'] ?? '') ?>" class="form-input" maxlength="50" placeholder="2026/15">
</div>
<div class="form-group">
<label class="form-label">تاريخ القرار</label>
<input type="date" name="board_decision_date" value="<?= e($discount['board_decision_date'] ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;">
</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;">
......
......@@ -45,6 +45,7 @@
<th>يُطبَّق على</th>
<th>الشرط</th>
<th>المكافأة</th>
<th>قرار مجلس الإدارة</th>
<th>الفترة</th>
<th>الحالة</th>
<th></th>
......@@ -96,6 +97,14 @@
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td style="font-size:12px;">
<?php if (!empty($row['board_decision_number'])): ?>
<span style="color:#0D7377;font-weight:600;"><?= e($row['board_decision_number']) ?></span>
<?php if (!empty($row['board_decision_date'])): ?><br><span style="color:#9CA3AF;font-size:11px;"><?= e($row['board_decision_date']) ?></span><?php endif; ?>
<?php else: ?>
<span style="color:#DC2626;" title="لن يظهر في قائمة اختيار العضو بدون رقم قرار">بدون قرار</span>
<?php endif; ?>
</td>
<td style="font-size:11px;color:#6B7280;">
<?php
$from = $row['effective_from'] ?? null;
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `board_offers`
ADD COLUMN `inst_down_payment_type` ENUM('percentage','fixed_amount') NULL DEFAULT 'percentage' AFTER `inst_down_payment_pct`,
ADD COLUMN `inst_down_payment_fixed` DECIMAL(15,2) NULL AFTER `inst_down_payment_type`;
UPDATE `board_offers` SET `inst_down_payment_type` = 'percentage' WHERE `inst_down_payment_pct` IS NOT NULL AND `inst_down_payment_type` IS NULL",
'down' => "
ALTER TABLE `board_offers`
DROP COLUMN `inst_down_payment_type`,
DROP COLUMN `inst_down_payment_fixed`",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `special_discounts`
ADD COLUMN `board_decision_number` VARCHAR(50) NULL AFTER `description`,
ADD COLUMN `board_decision_date` DATE NULL AFTER `board_decision_number`",
'down' => "
ALTER TABLE `special_discounts`
DROP COLUMN `board_decision_number`,
DROP COLUMN `board_decision_date`",
];
......@@ -90,7 +90,7 @@ app/Modules/Members/
| gender | VARCHAR | male/female |
| qualification_id | INT FK→qualifications | Determines pricing |
| membership_value | DECIMAL | Stored for reference |
| special_discount_id | INT FK→special_discounts | |
| special_discount_id | INT FK→special_discounts | Selectable only from board-approved discounts (has `board_decision_number`) — see Pricing.md |
| discount_amount | DECIMAL | |
| payment_method | VARCHAR | cash/installment/check/visa/transfer |
| activated_by_payment_id | INT FK→payments | Links to activating payment |
......@@ -121,7 +121,8 @@ app/Modules/Members/
- `death_cases` — FK member_id, transferred_to_member_id
- `transfer_requests` — source_member_id, child_id, target_member_id
- `divorce_cases` — FK member_id/original_member_id
- `board_offers` — is_active, effective_from/to, cash_discount_type/value
- `board_offers` — is_active, effective_from/to, cash_discount_type/value, inst_down_payment_type
(percentage/fixed_amount) + inst_down_payment_pct/inst_down_payment_fixed (NEW, mutually exclusive)
- `board_offer_tiers` — FK board_offer_id, tier_order, payment_type (cash/installment), cash_discount_pct, down_payment_pct, months, interest_rate
- `pricing_configs` — branch_id, qualification_id, membership_type, price
......
......@@ -38,9 +38,24 @@ app/Modules/Pricing/
### board_offers
- Board-approved promotional offers with date ranges and tier-based pricing
- Cash path: `cash_discount_type` (percentage/fixed_amount) + `cash_discount_value`
- Installment path down payment: `inst_down_payment_type` (percentage/fixed_amount, NEW) selects whether
`inst_down_payment_pct` (%) or `inst_down_payment_fixed` (ج.م, NEW) applies. Only one of the two value
columns is populated at a time. Flows into `BoardOfferService::getInstallmentTerms()/getInstallmentOverrides()`
as `down_fixed`/`min_down_amount`, consumed by `InstallmentCalculator::calculate()` as a hard minimum
down-payment amount instead of a percentage-of-total when set.
- `board_decision_number` / `board_decision_date` — the board decision this offer originates from
### special_discounts
- Individual member discounts (manual or computed), supports free subscription years
- `board_decision_number` / `board_decision_date` (NEW, nullable) — the board decision authorizing this
discount. **Business rule**: only discounts with a non-empty `board_decision_number` are offered in the
member-level "special discount" dropdown (`SpecialDiscount::allActiveBoardApproved()`), which is what
`MemberController` uses to populate `availableDiscounts`/`specialDiscounts` and to validate a submitted
`special_discount_id` on assignment. Discounts without a decision number still exist in the catalog (e.g.
for record-keeping or the auto-applied `condition_type` bonus flow in `SpecialDiscountService`) but cannot
be manually assigned to a member. Already-assigned discounts on existing members are unaffected regardless
of whether they have a decision number (no retroactive changes).
### regulatory_discounts (NEW)
- Bylaw-mandated discount rules (Articles 97-102, 110)
......@@ -133,3 +148,13 @@ Regulatory discounts are NOT stackable — system picks the highest applicable o
## API Endpoints
- `POST /pricing/regulatory-discounts/check-eligibility` — JSON API for real-time eligibility check from membership forms
## Change Log
- 2026-08-30: `special_discounts` gained `board_decision_number`/`board_decision_date` (migration
`Phase_105_002`); member-facing discount dropdown now sourced only from board-approved entries
(`SpecialDiscount::allActiveBoardApproved()`). `board_offers` gained a down-payment type toggle
(migration `Phase_105_001`, `inst_down_payment_type`/`inst_down_payment_fixed`) so the installment
down payment can be a fixed cash amount instead of only a percentage. Root cause: client feedback
that the special-discount dropdown pulled from an unaudited catalog instead of board-approved
offers, and that board offers could not express a fixed-amount down payment. See
`docs/architecture-maps/Members.md` for the consuming side (`MemberController`, `show.php`).
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