Commit 01e3b4ef authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(pricing,members): merge board-offer discounts into the member special-discount dropdown

Client clarified their earlier request: the member "special discount"
dropdown should show board-approved special_discounts AND active
عروض مجلس الإدارة (board_offers) cash discounts side by side, not one
instead of the other — they'd stopped seeing anything they'd added
under Board Offers.

- Add members.special_discount_source ('special_discount'|'board_offer')
  and drop the hard FK on special_discount_id (a single column can no
  longer FK exactly one table). Integrity is now validated in
  MemberController::parseDiscountSelection().
- BoardOffer::allActiveWithCashDiscount() surfaces board offers that
  define a cash discount as selectable options.
- SpecialDiscountService::resolveAssignedDiscount()/amountForType() give
  one place that normalizes "the member's assigned discount" across both
  source tables — used by BillingService's invoice line item, the show
  page's applied-discount banner, and the dropdown's own validation.
- fill-form/edit/show views render two <optgroup>s ("عروض مجلس الإدارة" /
  "الخصومات الخاصة") with prefixed option values (bo:<id> / sd:<id>) so a
  single form field can select from either table; edit.php's live
  discount-amount preview now handles fixed-amount discounts too, not
  just percentage.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 67196669
...@@ -28,7 +28,7 @@ class Member extends Model ...@@ -28,7 +28,7 @@ class Member extends Model
'area', 'governorate', 'correspondence_address', 'area', 'governorate', 'correspondence_address',
'employment_type', 'occupation', 'job_title', 'employment_date', 'employment_type', 'occupation', 'job_title', 'employment_date',
'business_address', 'office_phone', 'office_fax', 'business_activity', 'business_address', 'office_phone', 'office_fax', 'business_activity',
'membership_value', 'special_discount_id', 'special_discount_document', 'discount_amount', 'membership_value', 'special_discount_id', 'special_discount_source', 'special_discount_document', 'discount_amount',
'payment_method', 'referral_source', 'referred_by_representative_id', 'photo_path', 'payment_method', 'referral_source', 'referred_by_representative_id', 'photo_path',
'workflow_instance_id', 'workflow_instance_id',
]; ];
......
...@@ -7,6 +7,7 @@ use App\Core\App; ...@@ -7,6 +7,7 @@ use App\Core\App;
use App\Modules\ServiceCatalog\Models\ServicePrice; use App\Modules\ServiceCatalog\Models\ServicePrice;
use App\Modules\Members\Services\BoardOfferService; use App\Modules\Members\Services\BoardOfferService;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Pricing\Services\SpecialDiscountService;
/** /**
* Calculates the TOTAL bill for a member including all additions. * Calculates the TOTAL bill for a member including all additions.
...@@ -422,28 +423,17 @@ final class BillingService ...@@ -422,28 +423,17 @@ final class BillingService
'category' => 'required', 'category' => 'required',
]; ];
// ── 2b. Special Discount ── // ── 2b. Special Discount (from either special_discounts or board_offers — see
// SpecialDiscountService::resolveAssignedDiscount) ──
if (!empty($member['special_discount_id'])) { if (!empty($member['special_discount_id'])) {
$discountRow = $db->selectOne( $assigned = SpecialDiscountService::resolveAssignedDiscount($member);
"SELECT * FROM special_discounts sd WHERE sd.id = ? AND sd.is_active = 1", if ($assigned) {
[(int) $member['special_discount_id']] $discountAmount = ($assigned['discount_type'] === 'percentage' && !empty($member['discount_amount']))
); ? $member['discount_amount']
if ($discountRow) { : SpecialDiscountService::amountForType($assigned['discount_type'], $assigned['discount_value'], $membershipValue);
$today = date('Y-m-d'); $discountLabel = $assigned['discount_type'] === 'percentage'
$inRange = (empty($discountRow['effective_from']) || $today >= $discountRow['effective_from']) ? $assigned['label'] . ' (' . $assigned['discount_value'] . '%)'
&& (empty($discountRow['effective_to']) || $today <= $discountRow['effective_to']); : $assigned['label'] . ' (' . money($discountAmount) . ')';
if ($inRange) {
$discType = $discountRow['discount_type'] ?? 'percentage';
if ($discType === 'percentage') {
$discountAmount = $member['discount_amount'] ?? bcdiv(bcmul($membershipValue, $discountRow['discount_percentage'], 4), '100', 2);
$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) { if (bccomp($discountAmount, '0', 2) > 0) {
$items[] = [ $items[] = [
...@@ -456,7 +446,7 @@ final class BillingService ...@@ -456,7 +446,7 @@ final class BillingService
]; ];
} }
$bonusYears = (int) ($discountRow['bonus_free_subscription_years'] ?? 0); $bonusYears = $assigned['bonus_free_subscription_years'];
if ($bonusYears > 0) { if ($bonusYears > 0) {
$items[] = [ $items[] = [
'type' => 'bonus_info', 'type' => 'bonus_info',
...@@ -470,7 +460,6 @@ final class BillingService ...@@ -470,7 +460,6 @@ final class BillingService
} }
} }
} }
}
// ── 2c. Regulatory Discount ── // ── 2c. Regulatory Discount ──
if (!empty($member['regulatory_discount_id'])) { if (!empty($member['regulatory_discount_id'])) {
......
...@@ -94,7 +94,8 @@ ...@@ -94,7 +94,8 @@
</div> </div>
<!-- Special Discount --> <!-- Special Discount -->
<?php if (!empty($specialDiscounts)): ?> <?php if (!empty($specialDiscounts) || !empty($boardOfferDiscounts)): ?>
<?php $currentDiscountSource = $member->special_discount_source ?? 'special_discount'; ?>
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #D97706;"> <div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #D97706;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:15px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:15px;">
<i data-lucide="percent" style="width:18px;height:18px;color:#D97706;"></i> <i data-lucide="percent" style="width:18px;height:18px;color:#D97706;"></i>
...@@ -105,14 +106,32 @@ ...@@ -105,14 +106,32 @@
<label class="form-label">نوع الخصم الخاص</label> <label class="form-label">نوع الخصم الخاص</label>
<select name="special_discount_id" id="special_discount_select" class="form-select"> <select name="special_discount_id" id="special_discount_select" class="form-select">
<option value="">— بدون خصم —</option> <option value="">— بدون خصم —</option>
<?php if (!empty($boardOfferDiscounts)): ?>
<optgroup label="عروض مجلس الإدارة">
<?php foreach ($boardOfferDiscounts as $bo): ?>
<option value="bo:<?= (int) $bo['id'] ?>"
data-type="<?= e($bo['cash_discount_type']) ?>"
data-value="<?= e($bo['cash_discount_value']) ?>"
data-doc="0"
<?= ($currentDiscountSource === 'board_offer' && ((int) ($member->special_discount_id ?? 0)) === (int) $bo['id']) ? 'selected' : '' ?>>
<?= e($bo['title_ar']) ?> (<?= $bo['cash_discount_type'] === 'percentage' ? e($bo['cash_discount_value']) . '%' : money($bo['cash_discount_value']) ?>)
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if (!empty($specialDiscounts)): ?>
<optgroup label="الخصومات الخاصة">
<?php foreach ($specialDiscounts as $sd): ?> <?php foreach ($specialDiscounts as $sd): ?>
<option value="<?= (int) $sd['id'] ?>" <option value="sd:<?= (int) $sd['id'] ?>"
data-pct="<?= e($sd['discount_percentage']) ?>" data-type="<?= e($sd['discount_type'] ?? 'percentage') ?>"
data-value="<?= ($sd['discount_type'] ?? 'percentage') === 'fixed_amount' ? e($sd['fixed_amount']) : e($sd['discount_percentage']) ?>"
data-doc="<?= (int) $sd['requires_document'] ?>" data-doc="<?= (int) $sd['requires_document'] ?>"
<?= ((int) ($member->special_discount_id ?? 0)) === (int) $sd['id'] ? 'selected' : '' ?>> <?= ($currentDiscountSource === 'special_discount' && ((int) ($member->special_discount_id ?? 0)) === (int) $sd['id']) ? 'selected' : '' ?>>
<?= e($sd['name_ar']) ?> (<?= e($sd['discount_percentage']) ?>%) <?= e($sd['name_ar']) ?> (<?= ($sd['discount_type'] ?? 'percentage') === 'fixed_amount' ? money($sd['fixed_amount']) : e($sd['discount_percentage']) . '%' ?>)
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select> </select>
</div> </div>
<div class="form-group" id="discount-info" style="display:<?= $member->special_discount_id ? 'block' : 'none' ?>;"> <div class="form-group" id="discount-info" style="display:<?= $member->special_discount_id ? 'block' : 'none' ?>;">
...@@ -237,10 +256,11 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -237,10 +256,11 @@ document.addEventListener('DOMContentLoaded', function() {
docGroup.style.display = 'none'; docGroup.style.display = 'none';
return; return;
} }
var pct = parseFloat(opt.getAttribute('data-pct') || '0'); var type = opt.getAttribute('data-type') || 'percentage';
var value = parseFloat(opt.getAttribute('data-value') || '0');
var needsDoc = parseInt(opt.getAttribute('data-doc') || '0'); var needsDoc = parseInt(opt.getAttribute('data-doc') || '0');
var val = parseFloat(membershipValue) || 0; var val = parseFloat(membershipValue) || 0;
var discountAmt = (val * pct / 100).toFixed(2); var discountAmt = (type === 'fixed_amount' ? value : (val * value / 100)).toFixed(2);
amountDiv.textContent = discountAmt + ' ج.م'; amountDiv.textContent = discountAmt + ' ج.م';
infoDiv.style.display = 'block'; infoDiv.style.display = 'block';
docGroup.style.display = needsDoc ? 'block' : 'none'; docGroup.style.display = needsDoc ? 'block' : 'none';
......
...@@ -297,7 +297,8 @@ ...@@ -297,7 +297,8 @@
<!-- ═══════════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- القسم 6: خصم خاص (اختياري) --> <!-- القسم 6: خصم خاص (اختياري) -->
<!-- ═══════════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════════ -->
<?php if (!empty($specialDiscounts)): ?> <?php if (!empty($specialDiscounts) || !empty($boardOfferDiscounts)): ?>
<?php $currentDiscountSource = $member->special_discount_source ?? 'special_discount'; ?>
<div class="card" style="margin-bottom:20px;border-right:4px solid #D97706;"> <div class="card" style="margin-bottom:20px;border-right:4px solid #D97706;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#D97706;">خصم خاص (اختياري)</h3> <h3 style="margin:0;color:#D97706;">خصم خاص (اختياري)</h3>
...@@ -307,9 +308,20 @@ ...@@ -307,9 +308,20 @@
<label class="form-label">نوع الخصم الخاص</label> <label class="form-label">نوع الخصم الخاص</label>
<select name="special_discount_id" class="form-select"> <select name="special_discount_id" class="form-select">
<option value="">— بدون خصم —</option> <option value="">— بدون خصم —</option>
<?php if (!empty($boardOfferDiscounts)): ?>
<optgroup label="عروض مجلس الإدارة">
<?php foreach ($boardOfferDiscounts as $bo): ?>
<option value="bo:<?= (int) $bo['id'] ?>" <?= ($currentDiscountSource === 'board_offer' && ($member->special_discount_id ?? 0) == $bo['id']) ? 'selected' : '' ?>><?= e($bo['title_ar']) ?> (<?= $bo['cash_discount_type'] === 'percentage' ? e($bo['cash_discount_value']) . '%' : money($bo['cash_discount_value']) ?>)</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if (!empty($specialDiscounts)): ?>
<optgroup label="الخصومات الخاصة">
<?php foreach ($specialDiscounts as $sd): ?> <?php foreach ($specialDiscounts as $sd): ?>
<option value="<?= (int) $sd['id'] ?>" <?= ($member->special_discount_id ?? 0) == $sd['id'] ? 'selected' : '' ?>><?= e($sd['name_ar']) ?> (<?= e($sd['discount_percentage']) ?>%)</option> <option value="sd:<?= (int) $sd['id'] ?>" <?= ($currentDiscountSource === 'special_discount' && ($member->special_discount_id ?? 0) == $sd['id']) ? 'selected' : '' ?>><?= e($sd['name_ar']) ?> (<?= ($sd['discount_type'] ?? 'percentage') === 'fixed_amount' ? money($sd['fixed_amount']) : e($sd['discount_percentage']) . '%' ?>)</option>
<?php endforeach; ?> <?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
......
...@@ -376,11 +376,11 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -376,11 +376,11 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
</div> </div>
<!-- Special Discount Section --> <!-- Special Discount Section -->
<?php if (!empty($availableDiscounts) && in_array($member->status, ['accepted', 'payment_pending']) && empty($pendingMembership)): ?> <?php if ((!empty($availableDiscounts) || !empty($availableBoardOfferDiscounts)) && in_array($member->status, ['accepted', 'payment_pending']) && empty($pendingMembership)): ?>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-top:1px solid #E5E7EB;">
<?php if (!empty($specialDiscount)): ?> <?php if (!empty($specialDiscount)): ?>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="color:#059669;font-weight:600;font-size:13px;">&#x2705; خصم مُطبق: <?= e($specialDiscount['name_ar']) ?> (<?= e($specialDiscount['discount_percentage']) ?>%) = -<?= money($member->discount_amount ?? '0') ?></span> <span style="color:#059669;font-weight:600;font-size:13px;">&#x2705; خصم مُطبق: <?= e($specialDiscount['label']) ?> (<?= $specialDiscount['discount_type'] === 'percentage' ? e($specialDiscount['discount_value']) . '%' : money($specialDiscount['discount_value']) ?>) = -<?= money($member->discount_amount ?? '0') ?></span>
<form method="POST" action="/members/<?= (int) $member->id ?>/apply-discount" style="display:inline;"> <form method="POST" action="/members/<?= (int) $member->id ?>/apply-discount" style="display:inline;">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="special_discount_id" value="0"> <input type="hidden" name="special_discount_id" value="0">
...@@ -394,9 +394,20 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -394,9 +394,20 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
<label class="form-label" style="font-size:12px;">&#x1f3f7;&#xfe0f; خصم خاص</label> <label class="form-label" style="font-size:12px;">&#x1f3f7;&#xfe0f; خصم خاص</label>
<select name="special_discount_id" class="form-select" style="min-width:200px;" id="discountSelect" onchange="document.getElementById('discountDoc').style.display = this.selectedOptions[0].dataset.doc === '1' ? 'block' : 'none'"> <select name="special_discount_id" class="form-select" style="min-width:200px;" id="discountSelect" onchange="document.getElementById('discountDoc').style.display = this.selectedOptions[0].dataset.doc === '1' ? 'block' : 'none'">
<option value="">-- اختر خصم --</option> <option value="">-- اختر خصم --</option>
<?php if (!empty($availableBoardOfferDiscounts)): ?>
<optgroup label="عروض مجلس الإدارة">
<?php foreach ($availableBoardOfferDiscounts as $offer): ?>
<option value="bo:<?= (int) $offer['id'] ?>" data-doc="0"><?= e($offer['title_ar']) ?> (<?= $offer['cash_discount_type'] === 'percentage' ? e($offer['cash_discount_value']) . '%' : money($offer['cash_discount_value']) ?>)</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if (!empty($availableDiscounts)): ?>
<optgroup label="الخصومات الخاصة">
<?php foreach ($availableDiscounts as $disc): ?> <?php foreach ($availableDiscounts as $disc): ?>
<option value="<?= (int) $disc['id'] ?>" data-doc="<?= $disc['requires_document'] ? '1' : '0' ?>"><?= e($disc['name_ar']) ?> (<?= e($disc['discount_percentage']) ?>%)</option> <option value="sd:<?= (int) $disc['id'] ?>" data-doc="<?= $disc['requires_document'] ? '1' : '0' ?>"><?= e($disc['name_ar']) ?> (<?= ($disc['discount_type'] ?? 'percentage') === 'fixed_amount' ? money($disc['fixed_amount']) : e($disc['discount_percentage']) . '%' ?>)</option>
<?php endforeach; ?> <?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select> </select>
</div> </div>
<div class="form-group" style="margin:0;display:none;" id="discountDoc"> <div class="form-group" style="margin:0;display:none;" id="discountDoc">
......
...@@ -41,6 +41,23 @@ class BoardOffer extends Model ...@@ -41,6 +41,23 @@ class BoardOffer extends Model
); );
} }
/**
* Board offers selectable as a member's "special discount" — must define a cash discount
* (percentage or fixed amount), regardless of branch/installment settings.
*/
public static function allActiveWithCashDiscount(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
return $db->select(
"SELECT * FROM `board_offers`
WHERE `is_active` = 1 AND `cash_discount_type` IS NOT NULL AND `cash_discount_value` IS NOT NULL
AND `effective_from` <= ? AND `effective_to` >= ?
ORDER BY `title_ar`",
[$today, $today]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array public static function search(array $filters, int $perPage = 25, int $page = 1): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -24,17 +24,20 @@ final class SpecialDiscountService ...@@ -24,17 +24,20 @@ final class SpecialDiscountService
return self::emptyResult(); return self::emptyResult();
} }
// If the member has a specific discount assigned, use that // If the member has a specific discount assigned (from either special_discounts or
if (!empty($member['special_discount_id'])) { // board_offers — see resolveAssignedDiscount), use that.
$discount = $db->selectOne( $assigned = self::resolveAssignedDiscount($member);
"SELECT * FROM special_discounts WHERE id = ? AND is_active = 1", if ($assigned !== null) {
[(int) $member['special_discount_id']] $discountAmount = self::amountForType($assigned['discount_type'], $assigned['discount_value'], $membershipValue);
); return [
if ($discount) { 'discount_amount' => $discountAmount,
if (self::isWithinDateRange($discount, $today)) { 'discount_label' => $assigned['label'],
return self::calculateDiscount($discount, $membershipValue, $totalPaid); 'discount_id' => $assigned['id'],
} 'discount_type' => $assigned['discount_type'],
} 'percentage' => $assigned['discount_type'] === 'percentage' ? $assigned['discount_value'] : '0.00',
'bonus_free_years'=> $assigned['bonus_free_subscription_years'],
'applies_to' => $assigned['applies_to'],
];
} }
// Check for auto-applicable conditional discounts (full_payment, min_payment) // Check for auto-applicable conditional discounts (full_payment, min_payment)
...@@ -61,8 +64,11 @@ final class SpecialDiscountService ...@@ -61,8 +64,11 @@ final class SpecialDiscountService
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$today = date('Y-m-d'); $today = date('Y-m-d');
$member = $db->selectOne("SELECT special_discount_id, membership_value FROM members WHERE id = ?", [$memberId]); $member = $db->selectOne("SELECT special_discount_id, special_discount_source, membership_value FROM members WHERE id = ?", [$memberId]);
if (!$member || empty($member['special_discount_id'])) { if (!$member) return 0;
// Board-offer-sourced discounts have no bonus_free_subscription_years concept.
if (empty($member['special_discount_id']) || ($member['special_discount_source'] ?? 'special_discount') !== 'special_discount') {
// Check conditional discounts // Check conditional discounts
$membershipValue = $member['membership_value'] ?? '0.00'; $membershipValue = $member['membership_value'] ?? '0.00';
$totalPaid = self::getTotalMembershipPaid($db, $memberId); $totalPaid = self::getTotalMembershipPaid($db, $memberId);
...@@ -90,6 +96,73 @@ final class SpecialDiscountService ...@@ -90,6 +96,73 @@ final class SpecialDiscountService
return (int) $discount['bonus_free_subscription_years']; return (int) $discount['bonus_free_subscription_years'];
} }
/**
* Resolve the member's directly-assigned "special discount" from whichever source it came
* from — the special_discounts catalog (board-decision-backed entries only) or an active
* board_offers cash discount. Returns null if none assigned/active/in-range.
*/
public static function resolveAssignedDiscount(array $member): ?array
{
if (empty($member['special_discount_id'])) return null;
$db = App::getInstance()->db();
$today = date('Y-m-d');
$source = $member['special_discount_source'] ?? 'special_discount';
if ($source === 'board_offer') {
$offer = $db->selectOne(
"SELECT * FROM board_offers WHERE id = ? AND is_active = 1 AND cash_discount_type IS NOT NULL AND cash_discount_value IS NOT NULL",
[(int) $member['special_discount_id']]
);
if (!$offer || !self::isWithinDateRange($offer, $today)) return null;
return [
'source' => 'board_offer',
'id' => (int) $offer['id'],
'label' => 'عرض مجلس الإدارة: ' . $offer['title_ar'],
'discount_type' => $offer['cash_discount_type'],
'discount_value' => $offer['cash_discount_value'],
'requires_document' => false,
'bonus_free_subscription_years' => 0,
'applies_to' => $offer['applies_to'] ?? 'membership_fee',
'row' => $offer,
];
}
$discount = $db->selectOne(
"SELECT * FROM special_discounts WHERE id = ? AND is_active = 1",
[(int) $member['special_discount_id']]
);
if (!$discount || !self::isWithinDateRange($discount, $today)) return null;
$discType = $discount['discount_type'] ?? 'percentage';
return [
'source' => 'special_discount',
'id' => (int) $discount['id'],
'label' => 'خصم خاص: ' . $discount['name_ar'],
'discount_type' => $discType,
'discount_value' => $discType === 'fixed_amount' ? ($discount['fixed_amount'] ?? '0.00') : ($discount['discount_percentage'] ?? '0.00'),
'requires_document' => (bool) ($discount['requires_document'] ?? false),
'bonus_free_subscription_years' => (int) ($discount['bonus_free_subscription_years'] ?? 0),
'applies_to' => $discount['applies_to'] ?? 'membership_fee',
'row' => $discount,
];
}
/**
* Compute a discount amount for a normalized type ('percentage'|'fixed_amount'|'free_subscription').
*/
public static function amountForType(string $type, string $value, string $baseAmount): string
{
if ($type === 'percentage') {
return bcdiv(bcmul($baseAmount, $value, 4), '100', 2);
}
if ($type === 'fixed_amount') {
return $value;
}
return '0.00';
}
/** /**
* Apply free subscription bonus: mark N years of subscriptions as paid (discount = 100%). * Apply free subscription bonus: mark N years of subscriptions as paid (discount = 100%).
*/ */
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `members`
DROP FOREIGN KEY `fk_members_special_discount`;
ALTER TABLE `members`
ADD COLUMN `special_discount_source` ENUM('special_discount','board_offer') NOT NULL DEFAULT 'special_discount' AFTER `special_discount_id`;
UPDATE `members` SET `special_discount_source` = 'special_discount' WHERE `special_discount_id` IS NOT NULL",
'down' => "
ALTER TABLE `members`
DROP COLUMN `special_discount_source`;
ALTER TABLE `members`
ADD CONSTRAINT `fk_members_special_discount` FOREIGN KEY (`special_discount_id`) REFERENCES `special_discounts`(`id`) ON DELETE SET NULL",
];
...@@ -90,7 +90,8 @@ app/Modules/Members/ ...@@ -90,7 +90,8 @@ app/Modules/Members/
| gender | VARCHAR | male/female | | gender | VARCHAR | male/female |
| qualification_id | INT FK→qualifications | Determines pricing | | qualification_id | INT FK→qualifications | Determines pricing |
| membership_value | DECIMAL | Stored for reference | | membership_value | DECIMAL | Stored for reference |
| special_discount_id | INT FK→special_discounts | Selectable only from board-approved discounts (has `board_decision_number`) — see Pricing.md | | special_discount_id | BIGINT UNSIGNED, no FK (NEW) | Points into `special_discounts` OR `board_offers` depending on `special_discount_source`. No hard FK — a single column can't FK two tables; integrity is enforced in `MemberController::parseDiscountSelection()`. See Pricing.md |
| special_discount_source | ENUM('special_discount','board_offer') NOT NULL DEFAULT 'special_discount' (NEW) | Disambiguates which table `special_discount_id` points into. Resolve via `SpecialDiscountService::resolveAssignedDiscount($member)` rather than joining directly |
| discount_amount | DECIMAL | | | discount_amount | DECIMAL | |
| payment_method | VARCHAR | cash/installment/check/visa/transfer | | payment_method | VARCHAR | cash/installment/check/visa/transfer |
| activated_by_payment_id | INT FK→payments | Links to activating payment | | activated_by_payment_id | INT FK→payments | Links to activating payment |
...@@ -370,3 +371,14 @@ dropped → active (within 1 year + board approval + payment) ...@@ -370,3 +371,14 @@ dropped → active (within 1 year + board approval + payment)
- reconcile() on every page view is a hidden migration — should be event-driven - reconcile() on every page view is a hidden migration — should be event-driven
- Multiple financial year calculation functions duplicated across services - Multiple financial year calculation functions duplicated across services
- Board offer logic tightly coupled to payment request creation - Board offer logic tightly coupled to payment request creation
## Change Log
- 2026-08-30: The member "special discount" dropdown (fill-form, edit, show/apply-discount) now merges
two sources — board-approved `special_discounts` and active `board_offers` cash discounts — instead of
only the former. `members.special_discount_id` lost its FK to `special_discounts` (migration
`Phase_106_001`) and gained a sibling `special_discount_source` column so it can point at either table.
All reads of the member's assigned discount should go through
`SpecialDiscountService::resolveAssignedDiscount($member)` rather than joining `special_discounts`
directly — `BillingService::getMemberBill()` and `MemberController::show()` were updated to do this;
check for new direct `special_discount_id` joins before adding more. See Pricing.md's Change Log for
the full history of this feature (board-decision requirement, then the source merge).
...@@ -56,6 +56,13 @@ app/Modules/Pricing/ ...@@ -56,6 +56,13 @@ app/Modules/Pricing/
for record-keeping or the auto-applied `condition_type` bonus flow in `SpecialDiscountService`) but cannot 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 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). of whether they have a decision number (no retroactive changes).
- **Member dropdown is now merged with board_offers (NEW, 2026-08-30)** — the member-level "special discount"
select shows TWO optgroups: `عروض مجلس الإدارة` (from `BoardOffer::allActiveWithCashDiscount()` — active
board_offers with a cash discount configured) and `الخصومات الخاصة` (from
`SpecialDiscount::allActiveBoardApproved()`). Option values are prefixed (`bo:<id>` / `sd:<id>`) so a
single `special_discount_id` form field can select from either table. See `members.special_discount_source`
in Members.md and `SpecialDiscountService::resolveAssignedDiscount()` for how the two sources are unified
server-side.
### regulatory_discounts (NEW) ### regulatory_discounts (NEW)
- Bylaw-mandated discount rules (Articles 97-102, 110) - Bylaw-mandated discount rules (Articles 97-102, 110)
...@@ -158,3 +165,10 @@ Regulatory discounts are NOT stackable — system picks the highest applicable o ...@@ -158,3 +165,10 @@ Regulatory discounts are NOT stackable — system picks the highest applicable o
that the special-discount dropdown pulled from an unaudited catalog instead of board-approved 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 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`). `docs/architecture-maps/Members.md` for the consuming side (`MemberController`, `show.php`).
- 2026-08-30 (follow-up): client clarified the dropdown should show board_offers discounts *in addition
to* board-approved special_discounts, not instead of them (they'd stopped seeing anything they'd added
under عروض مجلس الإدارة). Added `members.special_discount_source` (migration `Phase_106_001`) and
merged both sources into one dropdown via `BoardOffer::allActiveWithCashDiscount()` +
`SpecialDiscountService::resolveAssignedDiscount()`/`amountForType()`. Dropped the hard FK
`fk_members_special_discount` (it can no longer be enforced against a single table) — integrity is now
app-level, validated in `MemberController::parseDiscountSelection()`.
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