Commit 9f69282f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(pricing): unified Board Offer system with cash discount + installment terms

- Rebuild board_offers table with unified schema (cash path + installment path in one record)
- Add grace period support (first_n_months and full_free_under_n modes)
- Add offer snapshot to payment_requests for persistence across offer expiry
- Add offer context (board_offer_id, grace_months) to installment_plans
- Refactor BoardOfferService with branch-aware offer resolution
- Enhance InstallmentCalculator with grace period math
- Full admin CRUD (BoardOfferController + views + routes + permissions)
- Integrate offer display in member show page payment section
- Snapshot offer at payment request creation, use snapshot in plan creation
- Fix BillingService to use new BoardOfferService API
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3902c88f
...@@ -68,6 +68,8 @@ final class PaymentRequestService ...@@ -68,6 +68,8 @@ final class PaymentRequestService
'requested_by' => $employee ? (int) $employee->id : 0, 'requested_by' => $employee ? (int) $employee->id : 0,
'branch_id' => $branch ? (int) $branch['id'] : null, 'branch_id' => $branch ? (int) $branch['id'] : null,
'notes' => $notes, 'notes' => $notes,
'board_offer_id' => $data['board_offer_id'] ?? null,
'offer_snapshot_json' => $data['offer_snapshot_json'] ?? null,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]); ]);
......
...@@ -78,9 +78,11 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -78,9 +78,11 @@ EventBus::listen('payment_request.completed', function (array $data) {
if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee'], true)) { if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee'], true)) {
$requestData = []; $requestData = [];
if ($paymentType === 'down_payment' && $requestId > 0) { if ($paymentType === 'down_payment' && $requestId > 0) {
$request = $db->selectOne("SELECT notes FROM payment_requests WHERE id = ?", [$requestId]); $request = $db->selectOne("SELECT notes, offer_snapshot_json, board_offer_id FROM payment_requests WHERE id = ?", [$requestId]);
$notesData = $request && $request['notes'] ? json_decode($request['notes'], true) : []; $notesData = $request && $request['notes'] ? json_decode($request['notes'], true) : [];
$requestData['installment_months'] = $notesData['installment_months'] ?? 30; $requestData['installment_months'] = $notesData['installment_months'] ?? 30;
$requestData['offer_snapshot_json'] = $request['offer_snapshot_json'] ?? null;
$requestData['board_offer_id'] = $request['board_offer_id'] ?? null;
} }
$result = \App\Modules\Payments\Services\PaymentLifecycleService::onMembershipPaymentCompleted( $result = \App\Modules\Payments\Services\PaymentLifecycleService::onMembershipPaymentCompleted(
......
...@@ -11,7 +11,7 @@ final class InstallmentCalculator ...@@ -11,7 +11,7 @@ final class InstallmentCalculator
/** /**
* Calculate a full installment plan with schedule. * Calculate a full installment plan with schedule.
*/ */
public static function calculate(string $totalAmount, string $downPayment, int $months, ?string $startDate = null): array public static function calculate(string $totalAmount, string $downPayment, int $months, ?string $startDate = null, array $offerOverrides = []): array
{ {
$interestData = RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $interestData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$annualRate = $interestData['percentage'] ?? '22.00'; $annualRate = $interestData['percentage'] ?? '22.00';
...@@ -22,15 +22,29 @@ final class InstallmentCalculator ...@@ -22,15 +22,29 @@ final class InstallmentCalculator
$maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); $maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$maxMonths = $maxMonthsData['months'] ?? 30; $maxMonths = $maxMonthsData['months'] ?? 30;
$offerOverrides = BoardOfferService::getInstallmentOverrides(); // Apply offer overrides (passed directly or from active board offer)
if (empty($offerOverrides)) {
$offerOverrides = BoardOfferService::getInstallmentOverrides();
}
if (!empty($offerOverrides)) { if (!empty($offerOverrides)) {
if (isset($offerOverrides['max_months'])) $maxMonths = $offerOverrides['max_months']; if (isset($offerOverrides['max_months'])) $maxMonths = $offerOverrides['max_months'];
if (isset($offerOverrides['interest_rate'])) $annualRate = $offerOverrides['interest_rate']; if (isset($offerOverrides['interest_rate'])) $annualRate = $offerOverrides['interest_rate'];
if (isset($offerOverrides['min_down_pct'])) $minDownPct = $offerOverrides['min_down_pct'];
}
$graceType = $offerOverrides['grace_type'] ?? null;
$graceMonths = (int) ($offerOverrides['grace_months'] ?? 0);
$postGraceRate = $offerOverrides['post_grace_rate'] ?? null;
// Mode B: full_free_under_n — 0% if months <= graceMonths
if ($graceType === 'full_free_under_n' && $months <= $graceMonths) {
$annualRate = '0.00';
$graceType = null;
$graceMonths = 0;
} }
$errors = []; $errors = [];
// Validate down payment >= 25%
$minDown = bcdiv(bcmul($totalAmount, $minDownPct, 4), '100', 2); $minDown = bcdiv(bcmul($totalAmount, $minDownPct, 4), '100', 2);
if (bccomp($downPayment, $minDown, 2) < 0) { if (bccomp($downPayment, $minDown, 2) < 0) {
$errors[] = "الحد الأدنى للمقدم {$minDownPct}% = " . number_format((float) $minDown, 2) . ' ج.م'; $errors[] = "الحد الأدنى للمقدم {$minDownPct}% = " . number_format((float) $minDown, 2) . ' ج.م';
...@@ -44,6 +58,10 @@ final class InstallmentCalculator ...@@ -44,6 +58,10 @@ final class InstallmentCalculator
$errors[] = "عدد الأقساط يجب أن يكون بين 1 و {$maxMonths} شهر"; $errors[] = "عدد الأقساط يجب أن يكون بين 1 و {$maxMonths} شهر";
} }
if ($graceType === 'first_n_months' && $graceMonths >= $months) {
$errors[] = 'أشهر السماح يجب أن تكون أقل من إجمالي الأقساط';
}
if (!empty($errors)) { if (!empty($errors)) {
return ['success' => false, 'errors' => $errors]; return ['success' => false, 'errors' => $errors];
} }
...@@ -51,7 +69,12 @@ final class InstallmentCalculator ...@@ -51,7 +69,12 @@ final class InstallmentCalculator
$remaining = bcsub($totalAmount, $downPayment, 2); $remaining = bcsub($totalAmount, $downPayment, 2);
$startDate = $startDate ?: date('Y-m-d'); $startDate = $startDate ?: date('Y-m-d');
// Flat simple interest: remaining × (annual_rate/100) × (months/12) // Mode A: first_n_months grace — interest only on post-grace period
if ($graceType === 'first_n_months' && $graceMonths > 0) {
return self::calculateWithGrace($remaining, $months, $annualRate, $graceMonths, $postGraceRate, $startDate, $totalAmount, $downPayment, $minDownPct, $maxMonths);
}
// Standard flat simple interest
$totalInterest = bcmul( $totalInterest = bcmul(
bcmul($remaining, bcdiv($annualRate, '100', 10), 10), bcmul($remaining, bcdiv($annualRate, '100', 10), 10),
bcdiv((string) $months, '12', 10), bcdiv((string) $months, '12', 10),
...@@ -60,9 +83,8 @@ final class InstallmentCalculator ...@@ -60,9 +83,8 @@ final class InstallmentCalculator
$totalWithInterest = bcadd($remaining, $totalInterest, 2); $totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2); $monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
// Per-instalment: equal share of interest, flat principal
$monthlyPrincipal = bcdiv($remaining, (string) $months, 2); $monthlyPrincipal = bcdiv($remaining, (string) $months, 2);
$monthlyInterest = bcdiv($totalInterest, (string) $months, 2); $monthlyInterest = bcdiv($totalInterest, (string) $months, 2);
$schedule = []; $schedule = [];
$runningBalance = $remaining; $runningBalance = $remaining;
...@@ -70,8 +92,8 @@ final class InstallmentCalculator ...@@ -70,8 +92,8 @@ final class InstallmentCalculator
for ($i = 1; $i <= $months; $i++) { for ($i = 1; $i <= $months; $i++) {
$isLast = ($i === $months); $isLast = ($i === $months);
$principal = $isLast ? $runningBalance : $monthlyPrincipal; $principal = $isLast ? $runningBalance : $monthlyPrincipal;
$interest = $isLast $interest = $isLast
? bcsub($totalWithInterest, bcadd($remaining, bcsub($totalInterest, $monthlyInterest, 2), 2), 2) // absorb rounding ? bcsub($totalWithInterest, bcadd($remaining, bcsub($totalInterest, $monthlyInterest, 2), 2), 2)
: $monthlyInterest; : $monthlyInterest;
if (bccomp($interest, '0', 2) < 0) $interest = '0.00'; if (bccomp($interest, '0', 2) < 0) $interest = '0.00';
$amount = bcadd($principal, $interest, 2); $amount = bcadd($principal, $interest, 2);
...@@ -88,12 +110,10 @@ final class InstallmentCalculator ...@@ -88,12 +110,10 @@ final class InstallmentCalculator
'principal' => $principal, 'principal' => $principal,
'interest' => $interest, 'interest' => $interest,
'remaining_after' => $runningBalance, 'remaining_after' => $runningBalance,
'is_grace' => false,
]; ];
} }
$avgMonthly = $monthlyPayment;
// Cash settlement date (30 days from start)
$cashWindowData = RuleEngine::get('CASH_PAYMENT_WINDOW'); $cashWindowData = RuleEngine::get('CASH_PAYMENT_WINDOW');
$cashDays = $cashWindowData['days'] ?? 30; $cashDays = $cashWindowData['days'] ?? 30;
$cashSettlementDate = date('Y-m-d', strtotime($startDate . " +{$cashDays} days")); $cashSettlementDate = date('Y-m-d', strtotime($startDate . " +{$cashDays} days"));
...@@ -107,10 +127,90 @@ final class InstallmentCalculator ...@@ -107,10 +127,90 @@ final class InstallmentCalculator
'total_interest' => $totalInterest, 'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest, 'total_with_interest' => $totalWithInterest,
'number_of_months' => $months, 'number_of_months' => $months,
'monthly_payment' => $monthlyPayment,
'start_date' => $startDate,
'cash_settlement_date'=> $cashSettlementDate,
'cash_days' => $cashDays,
'grace_months' => 0,
'grace_type' => null,
'schedule' => $schedule,
];
}
private static function calculateWithGrace(string $remaining, int $months, string $annualRate, int $graceMonths, ?string $postGraceRate, string $startDate, string $totalAmount, string $downPayment, string $minDownPct, int $maxMonths): array
{
$effectiveRate = $postGraceRate ?? $annualRate;
$postGraceMonths = $months - $graceMonths;
// Interest calculated only on post-grace months
$totalInterest = bcmul(
bcmul($remaining, bcdiv($effectiveRate, '100', 10), 10),
bcdiv((string) $postGraceMonths, '12', 10),
2
);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
// Equal principal across ALL months
$monthlyPrincipal = bcdiv($remaining, (string) $months, 2);
// Interest spread across post-grace months only
$monthlyInterest = $postGraceMonths > 0 ? bcdiv($totalInterest, (string) $postGraceMonths, 2) : '0.00';
$schedule = [];
$runningBalance = $remaining;
for ($i = 1; $i <= $months; $i++) {
$isLast = ($i === $months);
$isGrace = ($i <= $graceMonths);
$principal = $isLast ? $runningBalance : $monthlyPrincipal;
$interest = $isGrace ? '0.00' : $monthlyInterest;
if ($isLast && !$isGrace) {
// Absorb rounding in last post-grace month
$paidInterest = bcmul($monthlyInterest, (string) ($postGraceMonths - 1), 2);
$interest = bcsub($totalInterest, $paidInterest, 2);
if (bccomp($interest, '0', 2) < 0) $interest = '0.00';
}
$amount = bcadd($principal, $interest, 2);
$runningBalance = bcsub($runningBalance, $principal, 2);
if (bccomp($runningBalance, '0', 2) < 0) $runningBalance = '0.00';
$dueDate = date('Y-m-d', strtotime($startDate . " +{$i} months"));
$schedule[] = [
'number' => $i,
'due_date' => $dueDate,
'amount' => $amount,
'principal' => $principal,
'interest' => $interest,
'remaining_after' => $runningBalance,
'is_grace' => $isGrace,
];
}
$avgMonthly = bcdiv($totalWithInterest, (string) $months, 2);
$cashWindowData = RuleEngine::get('CASH_PAYMENT_WINDOW');
$cashDays = $cashWindowData['days'] ?? 30;
$cashSettlementDate = date('Y-m-d', strtotime($startDate . " +{$cashDays} days"));
return [
'success' => true,
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining_balance' => $remaining,
'interest_rate' => $effectiveRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'number_of_months' => $months,
'monthly_payment' => $avgMonthly, 'monthly_payment' => $avgMonthly,
'start_date' => $startDate, 'start_date' => $startDate,
'cash_settlement_date'=> $cashSettlementDate, 'cash_settlement_date'=> $cashSettlementDate,
'cash_days' => $cashDays, 'cash_days' => $cashDays,
'grace_months' => $graceMonths,
'grace_type' => 'first_n_months',
'schedule' => $schedule, 'schedule' => $schedule,
]; ];
} }
...@@ -131,7 +231,6 @@ final class InstallmentCalculator ...@@ -131,7 +231,6 @@ final class InstallmentCalculator
$remainingPrincipal = bcadd($remainingPrincipal, $item['principal'], 2); $remainingPrincipal = bcadd($remainingPrincipal, $item['principal'], 2);
} }
// Only first pending item's interest is due (current month)
$currentInterest = '0.00'; $currentInterest = '0.00';
if (!empty($items)) { if (!empty($items)) {
$currentInterest = $items[0]['interest'] ?? '0.00'; $currentInterest = $items[0]['interest'] ?? '0.00';
...@@ -154,17 +253,6 @@ final class InstallmentCalculator ...@@ -154,17 +253,6 @@ final class InstallmentCalculator
/** /**
* Calculate early settlement: member pays ALL remaining principal now, ALL future interest is waived. * Calculate early settlement: member pays ALL remaining principal now, ALL future interest is waived.
*
* Business rule: interest waiver is only valid when every single pending/overdue installment
* is being settled in the same transaction. Partial settlement is not allowed here.
*
* Returns:
* - pending_items : the unpaid schedule rows
* - items_count : how many will be settled
* - total_original_due : sum of amount (principal+interest) across all pending rows
* - remaining_principal : sum of principal only — what the member actually pays
* - total_interest_waived: sum of interest across all pending rows — fully waived
* - settlement_amount : equals remaining_principal (what is charged)
*/ */
public static function calculateEarlySettlement(int $planId): array public static function calculateEarlySettlement(int $planId): array
{ {
...@@ -184,12 +272,12 @@ final class InstallmentCalculator ...@@ -184,12 +272,12 @@ final class InstallmentCalculator
]; ];
} }
$totalOriginalDue = '0.00'; $totalOriginalDue = '0.00';
$remainingPrincipal = '0.00'; $remainingPrincipal = '0.00';
$totalInterestWaived = '0.00'; $totalInterestWaived = '0.00';
foreach ($items as $item) { foreach ($items as $item) {
$totalOriginalDue = bcadd($totalOriginalDue, (string) $item['amount'], 2); $totalOriginalDue = bcadd($totalOriginalDue, (string) $item['amount'], 2);
$remainingPrincipal = bcadd($remainingPrincipal, (string) $item['principal'], 2); $remainingPrincipal = bcadd($remainingPrincipal, (string) $item['principal'], 2);
$totalInterestWaived = bcadd($totalInterestWaived, (string) $item['interest'], 2); $totalInterestWaived = bcadd($totalInterestWaived, (string) $item['interest'], 2);
} }
...@@ -204,4 +292,4 @@ final class InstallmentCalculator ...@@ -204,4 +292,4 @@ final class InstallmentCalculator
'settlement_amount' => $remainingPrincipal, 'settlement_amount' => $remainingPrincipal,
]; ];
} }
} }
\ No newline at end of file
...@@ -22,6 +22,7 @@ use App\Modules\Forms\Services\FormBridge; ...@@ -22,6 +22,7 @@ use App\Modules\Forms\Services\FormBridge;
use App\Core\Logger; use App\Core\Logger;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService; use App\Modules\Members\Services\MembershipRulesService;
use App\Modules\Members\Services\BoardOfferService;
class MemberController extends Controller class MemberController extends Controller
{ {
...@@ -359,6 +360,11 @@ class MemberController extends Controller ...@@ -359,6 +360,11 @@ class MemberController extends Controller
$instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); $instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$boardOffers = BoardOfferService::getActiveOffers((int) ($member->branch_id ?? 0) ?: null);
$bestOffer = $boardOffers[0] ?? null;
$cashDiscount = $bestOffer ? BoardOfferService::getCashDiscount($bill['total_pending'], $bestOffer) : null;
$installmentTerms = BoardOfferService::getInstallmentTerms($bestOffer);
return $this->view('Members.Views.show', [ return $this->view('Members.Views.show', [
'member' => $member, 'member' => $member,
'branchName' => $branch['name_ar'] ?? '—', 'branchName' => $branch['name_ar'] ?? '—',
...@@ -385,6 +391,10 @@ class MemberController extends Controller ...@@ -385,6 +391,10 @@ class MemberController extends Controller
: null, : null,
'installInterestRate' => (float) ($instRateData['percentage'] ?? 22), 'installInterestRate' => (float) ($instRateData['percentage'] ?? 22),
'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30), 'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30),
'boardOffers' => $boardOffers,
'bestOffer' => $bestOffer,
'cashDiscount' => $cashDiscount,
'installmentTerms' => $installmentTerms,
'subscriptionStatus' => $subscriptionStatus, 'subscriptionStatus' => $subscriptionStatus,
'overdueSubscriptions' => $overdueSubscriptions, 'overdueSubscriptions' => $overdueSubscriptions,
'transferFeePayment' => $transferFeePayment, 'transferFeePayment' => $transferFeePayment,
...@@ -480,8 +490,19 @@ class MemberController extends Controller ...@@ -480,8 +490,19 @@ class MemberController extends Controller
if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح'); if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح');
$instMaxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); // Resolve active board offer (if any) at request time
$instMaxMonths = (int) ($instMaxMonthsData['months'] ?? 30); $offerId = (int) $request->post('board_offer_id', 0);
$activeOffer = null;
if ($offerId > 0) {
$activeOffer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ? AND `is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?", [$offerId, date('Y-m-d'), date('Y-m-d')]);
}
if (!$activeOffer) {
$activeOffer = BoardOfferService::getBestOffer((int) ($member->branch_id ?? 0) ?: null);
}
$offerSnapshot = $activeOffer ? BoardOfferService::snapshotOffer($activeOffer) : null;
$instTerms = BoardOfferService::getInstallmentTerms($activeOffer);
$instMaxMonths = $instTerms['max_months'];
$months = ($paymentType === 'down_payment') ? min($instMaxMonths, max(1, (int) $request->post('installment_months', $instMaxMonths))) : null; $months = ($paymentType === 'down_payment') ? min($instMaxMonths, max(1, (int) $request->post('installment_months', $instMaxMonths))) : null;
// Cancel any pending individual addition_fee requests — the collective payment subsumes them // Cancel any pending individual addition_fee requests — the collective payment subsumes them
...@@ -503,8 +524,17 @@ class MemberController extends Controller ...@@ -503,8 +524,17 @@ class MemberController extends Controller
if ($paymentType === 'down_payment' && $months) { if ($paymentType === 'down_payment' && $months) {
$breakdown[] = '💵 مقدم التقسيط: ' . money($amount); $breakdown[] = '💵 مقدم التقسيط: ' . money($amount);
$breakdown[] = '📅 عدد الأشهر: ' . $months; $breakdown[] = '📅 عدد الأشهر: ' . $months;
if ($activeOffer) {
$breakdown[] = '🎯 عرض مجلس الإدارة: ' . ($activeOffer['title_ar'] ?? '');
}
} else { } else {
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount); $breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount);
if ($activeOffer && $activeOffer['cash_discount_type']) {
$cashInfo = BoardOfferService::getCashDiscount($bill['total_pending'], $activeOffer);
if (bccomp($cashInfo['savings'], '0', 2) > 0) {
$breakdown[] = '🎯 خصم عرض مجلس الإدارة: ' . money($cashInfo['savings']);
}
}
} }
$notesData = ['fee_breakdown' => $breakdown]; $notesData = ['fee_breakdown' => $breakdown];
...@@ -527,6 +557,8 @@ class MemberController extends Controller ...@@ -527,6 +557,8 @@ class MemberController extends Controller
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => $descriptionAr, 'description_ar' => $descriptionAr,
'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE), 'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE),
'board_offer_id' => $activeOffer ? (int) $activeOffer['id'] : null,
'offer_snapshot_json' => $offerSnapshot,
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
......
...@@ -646,17 +646,23 @@ final class BillingService ...@@ -646,17 +646,23 @@ final class BillingService
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
// ── 6. Board Offers (cash discount) ── // ── 6. Board Offers (informational — actual discount applied at payment time) ──
$cashOffer = BoardOfferService::getCashDiscount(); $bestOffer = BoardOfferService::getBestOffer();
if ($cashOffer && ($cashOffer['applies_to'] === 'membership_fee' || $cashOffer['applies_to'] === 'all')) { if ($bestOffer && ($bestOffer['applies_to'] === 'membership_fee' || $bestOffer['applies_to'] === 'all')) {
$offerLabel = 'عرض مجلس الإدارة: ' . $bestOffer['title_ar'];
if ($bestOffer['cash_discount_type'] === 'percentage') {
$offerLabel .= ' (خصم ' . $bestOffer['cash_discount_value'] . '% كاش)';
} elseif ($bestOffer['cash_discount_type'] === 'fixed_amount') {
$offerLabel .= ' (خصم ' . number_format((float) $bestOffer['cash_discount_value'], 2) . ' ج.م كاش)';
}
$items[] = [ $items[] = [
'type' => 'board_offer', 'type' => 'board_offer',
'label' => 'عرض مجلس الإدارة: ' . $cashOffer['title_ar'] . ' (' . $cashOffer['discount_percentage'] . '%)', 'label' => $offerLabel,
'amount' => '0.00', 'amount' => '0.00',
'paid' => false, 'paid' => false,
'included' => false, 'included' => false,
'category' => 'offer', 'category' => 'offer',
'offer' => $cashOffer, 'offer' => $bestOffer,
]; ];
} }
......
...@@ -4,77 +4,177 @@ declare(strict_types=1); ...@@ -4,77 +4,177 @@ declare(strict_types=1);
namespace App\Modules\Members\Services; namespace App\Modules\Members\Services;
use App\Core\App; use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
final class BoardOfferService final class BoardOfferService
{ {
public static function getActiveOffers(string $type = ''): array public static function getActiveOffers(?int $branchId = null): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$today = date('Y-m-d'); $today = date('Y-m-d');
$where = "is_active = 1 AND effective_from <= ? AND effective_to >= ?"; $where = "`is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?";
$params = [$today, $today]; $params = [$today, $today];
if ($type !== '') { if ($branchId !== null) {
$where .= " AND offer_type = ?"; $where .= " AND (`branch_id` IS NULL OR `branch_id` = ?)";
$params[] = $type; $params[] = $branchId;
} }
return $db->select("SELECT * FROM board_offers WHERE {$where} ORDER BY effective_from DESC", $params); return $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `branch_id` DESC, `effective_from` DESC",
$params
);
} }
public static function getCashDiscount(): ?array public static function getBestOffer(?int $branchId = null): ?array
{ {
$offers = self::getActiveOffers('cash_discount'); $offers = self::getActiveOffers($branchId);
return $offers[0] ?? null; if (empty($offers)) {
return null;
}
// Branch-specific first (branch_id DESC puts non-null first)
return $offers[0];
} }
public static function getInstallmentTermsOverride(): ?array public static function getCashDiscount(string $amount, ?array $offer = null): array
{ {
$offers = self::getActiveOffers('installment_terms'); if ($offer === null) {
return $offers[0] ?? null; $offer = self::getBestOffer();
} }
public static function getSubscriptionDiscount(): ?array if (!$offer || !$offer['cash_discount_type'] || !$offer['cash_discount_value']) {
{ return [
$offers = self::getActiveOffers('subscription_discount'); 'original' => $amount,
return $offers[0] ?? null; 'discounted' => $amount,
'savings' => '0.00',
'type' => null,
'value' => null,
'offer' => null,
];
}
$savings = '0.00';
if ($offer['cash_discount_type'] === 'percentage') {
$savings = bcdiv(bcmul($amount, (string) $offer['cash_discount_value'], 4), '100', 2);
} else {
$savings = (string) $offer['cash_discount_value'];
if (bccomp($savings, $amount, 2) > 0) {
$savings = $amount;
}
}
$discounted = bcsub($amount, $savings, 2);
if (bccomp($discounted, '0', 2) < 0) {
$discounted = '0.00';
}
return [
'original' => $amount,
'discounted' => $discounted,
'savings' => $savings,
'type' => $offer['cash_discount_type'],
'value' => $offer['cash_discount_value'],
'offer' => $offer,
];
} }
public static function applyCashDiscount(string $amount): array public static function getInstallmentTerms(?array $offer = null): array
{ {
$offer = self::getCashDiscount(); $interestData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$minDownData = RuleEngine::get('INSTALLMENT_MIN_DOWN_PAYMENT');
$maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$defaults = [
'down_pct' => $minDownData['percentage'] ?? '25.00',
'max_months' => (int) ($maxMonthsData['months'] ?? 30),
'interest_rate' => $interestData['percentage'] ?? '22.00',
'grace_type' => null,
'grace_months' => 0,
'post_grace_rate' => null,
'offer' => null,
];
if (!$offer) { if (!$offer) {
return ['amount' => $amount, 'discount' => '0.00', 'offer' => null]; return $defaults;
} }
$pct = $offer['discount_percentage'] ?? '0'; $terms = $defaults;
$discount = bcdiv(bcmul($amount, $pct, 4), '100', 2); $terms['offer'] = $offer;
$final = bcsub($amount, $discount, 2);
return [ if ($offer['inst_down_payment_pct'] !== null) {
'amount' => $final, $terms['down_pct'] = (string) $offer['inst_down_payment_pct'];
'discount' => $discount, }
'offer' => $offer, if ($offer['inst_months'] !== null) {
]; $terms['max_months'] = (int) $offer['inst_months'];
}
if ($offer['inst_interest_rate'] !== null) {
$terms['interest_rate'] = (string) $offer['inst_interest_rate'];
}
if ($offer['inst_grace_type'] !== null) {
$terms['grace_type'] = $offer['inst_grace_type'];
$terms['grace_months'] = (int) $offer['inst_grace_months'];
}
if ($offer['inst_post_grace_rate'] !== null) {
$terms['post_grace_rate'] = (string) $offer['inst_post_grace_rate'];
}
return $terms;
} }
public static function getInstallmentOverrides(): array public static function getInstallmentOverrides(): array
{ {
$offer = self::getInstallmentTermsOverride(); $offer = self::getBestOffer();
if (!$offer) { if (!$offer) {
return []; return [];
} }
$overrides = []; $overrides = [];
if ($offer['custom_months']) { if ($offer['inst_months'] !== null) {
$overrides['max_months'] = (int) $offer['custom_months']; $overrides['max_months'] = (int) $offer['inst_months'];
}
if ($offer['inst_interest_rate'] !== null) {
$overrides['interest_rate'] = (string) $offer['inst_interest_rate'];
} }
if ($offer['custom_interest_rate'] !== null) { if ($offer['inst_down_payment_pct'] !== null) {
$overrides['interest_rate'] = $offer['custom_interest_rate']; $overrides['min_down_pct'] = (string) $offer['inst_down_payment_pct'];
}
if ($offer['inst_grace_type'] !== null) {
$overrides['grace_type'] = $offer['inst_grace_type'];
$overrides['grace_months'] = (int) $offer['inst_grace_months'];
if ($offer['inst_post_grace_rate'] !== null) {
$overrides['post_grace_rate'] = (string) $offer['inst_post_grace_rate'];
}
} }
$overrides['offer'] = $offer; $overrides['offer'] = $offer;
return $overrides; return $overrides;
} }
public static function snapshotOffer(array $offer): string
{
return json_encode([
'id' => $offer['id'],
'title_ar' => $offer['title_ar'],
'cash_discount_type' => $offer['cash_discount_type'],
'cash_discount_value' => $offer['cash_discount_value'],
'inst_down_payment_pct' => $offer['inst_down_payment_pct'],
'inst_months' => $offer['inst_months'],
'inst_interest_rate' => $offer['inst_interest_rate'],
'inst_grace_type' => $offer['inst_grace_type'],
'inst_grace_months' => $offer['inst_grace_months'],
'inst_post_grace_rate' => $offer['inst_post_grace_rate'],
'effective_from' => $offer['effective_from'],
'effective_to' => $offer['effective_to'],
], JSON_UNESCAPED_UNICODE);
}
public static function restoreFromSnapshot(string $json): ?array
{
$data = json_decode($json, true);
if (!is_array($data)) {
return null;
}
return $data;
}
} }
...@@ -475,35 +475,77 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -475,35 +475,77 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
?> ?>
<div style="padding:20px;background:#FFF7ED;border-top:2px solid #F59E0B;"> <div style="padding:20px;background:#FFF7ED;border-top:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">&#x1f4b0; اختر طريقة السداد وأرسل للخزينة</h4> <h4 style="margin:0 0 15px;color:#D97706;">&#x1f4b0; اختر طريقة السداد وأرسل للخزينة</h4>
<?php if (!empty($bestOffer)): ?>
<div style="background:linear-gradient(135deg,#FEF3C7,#FDE68A);border:1px solid #F59E0B;border-radius:8px;padding:10px 15px;margin-bottom:15px;display:flex;align-items:center;gap:10px;">
<span style="font-size:20px;">&#x1f381;</span>
<div>
<strong style="color:#92400E;font-size:13px;">عرض مجلس الإدارة: <?= e($bestOffer['title_ar']) ?></strong>
<?php if ($bestOffer['board_decision_number']): ?>
<span style="color:#78350F;font-size:11px;margin-right:8px;">(قرار <?= e($bestOffer['board_decision_number']) ?>)</span>
<?php endif; ?>
<div style="font-size:11px;color:#92400E;margin-top:2px;">ساري حتى <?= e($bestOffer['effective_to']) ?></div>
</div>
</div>
<?php endif; ?>
<?php
$cashAmount = $bill['total_pending'];
$cashSavings = '0.00';
if (!empty($cashDiscount) && bccomp($cashDiscount['savings'], '0', 2) > 0) {
$cashAmount = $cashDiscount['discounted'];
$cashSavings = $cashDiscount['savings'];
}
?>
<div style="display:grid;grid-template-columns:<?= $allowInstallment ? '1fr 1fr' : '1fr' ?>;gap:20px;"> <div style="display:grid;grid-template-columns:<?= $allowInstallment ? '1fr 1fr' : '1fr' ?>;gap:20px;">
<!-- Cash Full --> <!-- Cash Full -->
<div style="background:#fff;border:2px solid #059669;border-radius:12px;padding:20px;"> <div style="background:#fff;border:2px solid #059669;border-radius:12px;padding:20px;">
<h5 style="margin:0 0 10px;color:#059669;">&#x1f4b5; كاش كامل</h5> <h5 style="margin:0 0 10px;color:#059669;">&#x1f4b5; كاش كامل</h5>
<p style="font-size:13px;color:#6B7280;margin:0 0 10px;">ادفع المبلغ كاملاً — بدون فوائد</p> <p style="font-size:13px;color:#6B7280;margin:0 0 10px;">ادفع المبلغ كاملاً — بدون فوائد</p>
<div style="font-size:24px;font-weight:700;color:#059669;margin-bottom:15px;"><?= money($bill['total_pending']) ?></div> <?php if (bccomp($cashSavings, '0', 2) > 0): ?>
<div style="font-size:14px;color:#6B7280;text-decoration:line-through;margin-bottom:4px;"><?= money($bill['total_pending']) ?></div>
<div style="font-size:24px;font-weight:700;color:#059669;margin-bottom:4px;"><?= money($cashAmount) ?></div>
<div style="font-size:12px;color:#059669;background:#ECFDF5;display:inline-block;padding:2px 8px;border-radius:4px;margin-bottom:15px;">&#x2714; وفّرت <?= money($cashSavings) ?></div>
<?php else: ?>
<div style="font-size:24px;font-weight:700;color:#059669;margin-bottom:15px;"><?= money($cashAmount) ?></div>
<?php endif; ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership"> <form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="payment_type" value="<?= e($paymentTypeForType) ?>"> <input type="hidden" name="payment_type" value="<?= e($paymentTypeForType) ?>">
<input type="hidden" name="amount" value="<?= e($bill['total_pending']) ?>"> <input type="hidden" name="amount" value="<?= e($cashAmount) ?>">
<button type="submit" class="btn btn-primary" style="width:100%;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب دفع <?= money($bill['total_pending']) ?> للخزينة؟')">&#x1f4e4; إرسال للخزينة</button> <?php if (!empty($bestOffer)): ?>
<input type="hidden" name="board_offer_id" value="<?= (int) $bestOffer['id'] ?>">
<?php endif; ?>
<button type="submit" class="btn btn-primary" style="width:100%;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب دفع <?= money($cashAmount) ?> للخزينة؟')">&#x1f4e4; إرسال للخزينة</button>
</form> </form>
</div> </div>
<?php if ($allowInstallment): ?> <?php if ($allowInstallment): ?>
<?php <?php
$instRate = $installInterestRate ?? 22; $instRate = (float) ($installmentTerms['interest_rate'] ?? $installInterestRate ?? 22);
$instMaxMos = $installMaxMonths ?? 30; $instMaxMos = (int) ($installmentTerms['max_months'] ?? $installMaxMonths ?? 30);
$minDown = bcdiv(bcmul($bill['total_pending'], '25', 2), '100', 2); $instDownPct = (float) ($installmentTerms['down_pct'] ?? 25);
// Default preview: min down, max months $instGraceType = $installmentTerms['grace_type'] ?? null;
$instGraceMonths = (int) ($installmentTerms['grace_months'] ?? 0);
$minDown = bcdiv(bcmul($bill['total_pending'], (string) $instDownPct, 2), '100', 2);
$instRem = bcsub($bill['total_pending'], $minDown, 2); $instRem = bcsub($bill['total_pending'], $minDown, 2);
$instInterest = bcmul(bcmul($instRem, bcdiv((string) $instRate, '100', 8), 8), bcdiv((string) $instMaxMos, '12', 8), 2); $instInterest = bcmul(bcmul($instRem, bcdiv((string) $instRate, '100', 8), 8), bcdiv((string) $instMaxMos, '12', 8), 2);
// Mode B: full_free_under_n — 0% if months <= grace
if ($instGraceType === 'full_free_under_n' && $instMaxMos <= $instGraceMonths) {
$instInterest = '0.00';
}
$instTotalWI = bcadd($instRem, $instInterest, 2); $instTotalWI = bcadd($instRem, $instInterest, 2);
$instMonthly = bcdiv($instTotalWI, (string) $instMaxMos, 2); $instMonthly = bcdiv($instTotalWI, (string) $instMaxMos, 2);
?> ?>
<!-- Installment --> <!-- Installment -->
<div style="background:#fff;border:2px solid #0284C7;border-radius:12px;padding:20px;"> <div style="background:#fff;border:2px solid #0284C7;border-radius:12px;padding:20px;">
<h5 style="margin:0 0 4px;color:#0284C7;">&#x1f4c5; تقسيط</h5> <h5 style="margin:0 0 4px;color:#0284C7;">&#x1f4c5; تقسيط</h5>
<p style="font-size:12px;color:#6B7280;margin:0 0 2px;">مقدم 25% على الأقل + باقي على أقساط</p> <p style="font-size:12px;color:#6B7280;margin:0 0 2px;">مقدم <?= $instDownPct ?>% على الأقل + باقي على أقساط</p>
<p style="font-size:11px;color:#D97706;margin:0 0 12px;">فائدة <?= (float) $instRate ?>% سنوياً — حتى <?= $instMaxMos ?> شهر</p> <p style="font-size:11px;color:#D97706;margin:0 0 4px;">فائدة <?= $instRate ?>% سنوياً — حتى <?= $instMaxMos ?> شهر</p>
<?php if ($instGraceType === 'first_n_months' && $instGraceMonths > 0): ?>
<p style="font-size:11px;color:#7C3AED;margin:0 0 12px;background:#F3E8FF;display:inline-block;padding:2px 8px;border-radius:4px;">&#x1f389; أول <?= $instGraceMonths ?> شهر بدون فوائد</p>
<?php elseif ($instGraceType === 'full_free_under_n' && $instGraceMonths > 0): ?>
<p style="font-size:11px;color:#7C3AED;margin:0 0 12px;background:#F3E8FF;display:inline-block;padding:2px 8px;border-radius:4px;">&#x1f389; إعفاء كامل من الفوائد إذا الأقساط &#x2264; <?= $instGraceMonths ?> شهر</p>
<?php else: ?>
<div style="margin-bottom:8px;"></div>
<?php endif; ?>
<!-- Live breakdown (updates with JS) --> <!-- Live breakdown (updates with JS) -->
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:12px;"> <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:12px;">
...@@ -538,6 +580,9 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -538,6 +580,9 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership" novalidate> <form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership" novalidate>
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="payment_type" value="down_payment"> <input type="hidden" name="payment_type" value="down_payment">
<?php if (!empty($bestOffer)): ?>
<input type="hidden" name="board_offer_id" value="<?= (int) $bestOffer['id'] ?>">
<?php endif; ?>
<div class="form-group" style="margin-bottom:8px;"> <div class="form-group" style="margin-bottom:8px;">
<label class="form-label" style="font-size:12px;">المقدم (&#x2265; <?= money($minDown) ?>)</label> <label class="form-label" style="font-size:12px;">المقدم (&#x2265; <?= money($minDown) ?>)</label>
<input type="number" id="inst_down_input" name="amount" <input type="number" id="inst_down_input" name="amount"
...@@ -561,9 +606,11 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -561,9 +606,11 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
</div> </div>
<script> <script>
(function () { (function () {
var TOTAL = <?= json_encode((float) $bill['total_pending']) ?>; var TOTAL = <?= json_encode((float) $bill['total_pending']) ?>;
var RATE = <?= json_encode((float) $instRate) ?>; var RATE = <?= json_encode((float) $instRate) ?>;
var MIN_DOWN = <?= json_encode((float) $minDown) ?>; var MIN_DOWN = <?= json_encode((float) $minDown) ?>;
var GRACE_TYPE = <?= json_encode($instGraceType) ?>;
var GRACE_MOS = <?= json_encode($instGraceMonths) ?>;
var fmt = function(v) { return v.toLocaleString('ar-EG', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' ج.م'; }; var fmt = function(v) { return v.toLocaleString('ar-EG', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' ج.م'; };
window.instRecalc = function () { window.instRecalc = function () {
...@@ -572,8 +619,15 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -572,8 +619,15 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
if (down < MIN_DOWN) down = MIN_DOWN; if (down < MIN_DOWN) down = MIN_DOWN;
if (months < 1) months = 1; if (months < 1) months = 1;
var remaining = Math.round((TOTAL - down) * 100) / 100; var remaining = Math.round((TOTAL - down) * 100) / 100;
var totalInterest = Math.round(remaining * (RATE / 100) * (months / 12) * 100) / 100; var effectiveRate = RATE;
// Mode B: full_free_under_n — 0% if months <= grace
if (GRACE_TYPE === 'full_free_under_n' && months <= GRACE_MOS) {
effectiveRate = 0;
}
var totalInterest = Math.round(remaining * (effectiveRate / 100) * (months / 12) * 100) / 100;
var totalWI = Math.round((remaining + totalInterest) * 100) / 100; var totalWI = Math.round((remaining + totalInterest) * 100) / 100;
var monthly = Math.round((totalWI / months) * 100) / 100; var monthly = Math.round((totalWI / months) * 100) / 100;
...@@ -582,7 +636,7 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -582,7 +636,7 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
document.getElementById('inst_interest_disp').textContent = fmt(totalInterest); document.getElementById('inst_interest_disp').textContent = fmt(totalInterest);
document.getElementById('inst_totalwi_disp').textContent = fmt(totalWI); document.getElementById('inst_totalwi_disp').textContent = fmt(totalWI);
document.getElementById('inst_monthly_disp').textContent = fmt(monthly); document.getElementById('inst_monthly_disp').textContent = fmt(monthly);
document.getElementById('inst_interest_label').textContent = 'الفائدة (' + RATE + '% × ' + months + ' شهر)'; document.getElementById('inst_interest_label').textContent = 'الفائدة (' + effectiveRate + '% × ' + months + ' شهر)';
document.getElementById('inst_monthly_label').textContent = 'القسط الشهري (÷ ' + months + ')'; document.getElementById('inst_monthly_label').textContent = 'القسط الشهري (÷ ' + months + ')';
}; };
})(); })();
......
...@@ -214,47 +214,75 @@ final class PaymentLifecycleService ...@@ -214,47 +214,75 @@ final class PaymentLifecycleService
if (bccomp($remaining, '0', 2) <= 0) return; if (bccomp($remaining, '0', 2) <= 0) return;
$months = min(30, max(1, (int) ($requestData['installment_months'] ?? 30))); // Resolve installment terms from offer snapshot (if any) or defaults
$offerSnapshot = null;
$boardOfferId = null;
$graceMonths = 0;
$postGraceRate = null;
if (!empty($requestData['offer_snapshot_json'])) {
$offerSnapshot = \App\Modules\Members\Services\BoardOfferService::restoreFromSnapshot($requestData['offer_snapshot_json']);
$boardOfferId = (int) ($requestData['board_offer_id'] ?? $offerSnapshot['id'] ?? 0) ?: null;
}
$offerOverrides = [];
if ($offerSnapshot) {
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_grace_type'] !== null) {
$offerOverrides['grace_type'] = $offerSnapshot['inst_grace_type'];
$offerOverrides['grace_months'] = (int) $offerSnapshot['inst_grace_months'];
$graceMonths = (int) $offerSnapshot['inst_grace_months'];
if ($offerSnapshot['inst_post_grace_rate'] !== null) {
$offerOverrides['post_grace_rate'] = (string) $offerSnapshot['inst_post_grace_rate'];
$postGraceRate = (string) $offerSnapshot['inst_post_grace_rate'];
}
}
}
$maxMonths = (int) ($offerOverrides['max_months'] ?? 30);
$months = min($maxMonths, max(1, (int) ($requestData['installment_months'] ?? $maxMonths)));
$interestRateData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $calc = \App\Modules\Installments\Services\InstallmentCalculator::calculate(
$interestRate = $interestRateData['percentage'] ?? '22.00'; $membershipValue, $amount, $months, date('Y-m-d'), $offerOverrides
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2); );
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2); if (!($calc['success'] ?? false)) {
Logger::error("PaymentLifecycleService: installment calc failed", ['errors' => $calc['errors'] ?? [], 'member_id' => $memberId]);
return;
}
$planId = $db->insert('installment_plans', [ $planId = $db->insert('installment_plans', [
'member_id' => $memberId, 'member_id' => $memberId,
'related_entity_type'=> 'members', 'related_entity_type' => 'members',
'related_entity_id' => $memberId, 'related_entity_id' => $memberId,
'total_amount' => $membershipValue, 'total_amount' => $membershipValue,
'down_payment' => $amount, 'down_payment' => $amount,
'remaining_balance' => $remaining, 'remaining_balance' => $calc['remaining_balance'],
'interest_rate' => $interestRate, 'interest_rate' => $calc['interest_rate'],
'total_interest' => $totalInterest, 'total_interest' => $calc['total_interest'],
'total_with_interest'=> $totalWithInterest, 'total_with_interest' => $calc['total_with_interest'],
'number_of_months' => $months, 'number_of_months' => $months,
'monthly_payment' => $monthlyPayment, 'monthly_payment' => $calc['monthly_payment'],
'start_date' => date('Y-m-d'), 'start_date' => date('Y-m-d'),
'status' => 'active', 'status' => 'active',
'created_at' => date('Y-m-d H:i:s'), 'board_offer_id' => $boardOfferId,
'updated_at' => date('Y-m-d H:i:s'), 'grace_months' => $graceMonths,
'post_grace_interest_rate' => $postGraceRate,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]); ]);
$rem = $totalWithInterest; foreach ($calc['schedule'] as $row) {
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$instAmount = bcadd($principal, $interest, 2);
$rem = bcsub($rem, $instAmount, 2);
if (bccomp($rem, '0', 2) < 0) $rem = '0.00';
$db->insert('installment_schedule', [ $db->insert('installment_schedule', [
'installment_plan_id' => $planId, 'installment_plan_id' => $planId,
'installment_number' => $i, 'installment_number' => $row['number'],
'due_date' => date('Y-m-d', strtotime("+{$i} months")), 'due_date' => $row['due_date'],
'amount' => $instAmount, 'amount' => $row['amount'],
'principal' => $principal, 'principal' => $row['principal'],
'interest' => $interest, 'interest' => $row['interest'],
'remaining_after' => $rem, 'remaining_after' => $row['remaining_after'],
'paid_amount' => '0.00', 'paid_amount' => '0.00',
'status' => 'pending', 'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
...@@ -265,9 +293,9 @@ final class PaymentLifecycleService ...@@ -265,9 +293,9 @@ final class PaymentLifecycleService
EventBus::dispatch('installment.plan_created', [ EventBus::dispatch('installment.plan_created', [
'plan_id' => $planId, 'plan_id' => $planId,
'member_id' => $memberId, 'member_id' => $memberId,
'total_amount' => $totalWithInterest, 'total_amount' => $calc['total_with_interest'],
]); ]);
Logger::info("PaymentLifecycleService: installment plan created", ['plan_id' => $planId, 'member_id' => $memberId]); Logger::info("PaymentLifecycleService: installment plan created", ['plan_id' => $planId, 'member_id' => $memberId, 'grace_months' => $graceMonths]);
} }
} }
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Pricing\Models\BoardOffer;
class BoardOfferController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'is_active' => $request->get('is_active', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = BoardOffer::search($filters, 25, $page);
return $this->view('Pricing.Views.board_offers.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
$branches = $this->getBranches();
return $this->view('Pricing.Views.board_offers.form', [
'offer' => null,
'branches' => $branches,
]);
}
public function store(Request $request): Response
{
$data = $this->extractFormData($request);
$error = $this->validateOfferData($data);
if ($error) {
$session = App::getInstance()->session();
$session->flash('_old_input', $data);
return $this->redirect('/pricing/board-offers/create')->withError($error);
}
$employee = App::getInstance()->currentEmployee();
$data['created_by'] = $employee ? (int) $employee->id : null;
BoardOffer::create($data);
return $this->redirect('/pricing/board-offers')->withSuccess('تم إضافة عرض مجلس الإدارة بنجاح');
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$branches = $this->getBranches();
return $this->view('Pricing.Views.board_offers.form', [
'offer' => $offer,
'branches' => $branches,
]);
}
public function update(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$data = $this->extractFormData($request);
$data['is_active'] = (int) $request->post('is_active', 1);
$error = $this->validateOfferData($data);
if ($error) {
$session = App::getInstance()->session();
$session->flash('_old_input', $data);
return $this->redirect("/pricing/board-offers/{$id}/edit")->withError($error);
}
$employee = App::getInstance()->currentEmployee();
$data['updated_by'] = $employee ? (int) $employee->id : null;
$data['updated_at'] = date('Y-m-d H:i:s');
$db->update('board_offers', $data, '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/board-offers')->withSuccess('تم تحديث العرض بنجاح');
}
public function toggleActive(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$newActive = $offer['is_active'] ? 0 : 1;
$db->update('board_offers', [
'is_active' => $newActive,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/board-offers')->withSuccess($newActive ? 'تم تفعيل العرض' : 'تم تعطيل العرض');
}
private function extractFormData(Request $request): array
{
$hasCash = (int) $request->post('has_cash_path', 0);
$hasInst = (int) $request->post('has_inst_path', 0);
return [
'title_ar' => trim((string) $request->post('title_ar', '')),
'title_en' => trim((string) $request->post('title_en', '')) ?: null,
'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_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,
'inst_grace_months' => $hasInst ? (int) $request->post('inst_grace_months', 0) : 0,
'inst_post_grace_rate' => $hasInst ? (trim((string) $request->post('inst_post_grace_rate', '')) ?: null) : null,
'branch_id' => ((int) $request->post('branch_id', 0)) ?: null,
'applies_to' => $request->post('applies_to', 'membership_fee'),
'effective_from' => trim((string) $request->post('effective_from', '')),
'effective_to' => trim((string) $request->post('effective_to', '')),
'is_active' => 1,
'board_decision_number' => trim((string) $request->post('board_decision_number', '')) ?: null,
'board_decision_date' => trim((string) $request->post('board_decision_date', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
}
private function validateOfferData(array $data): ?string
{
if ($data['title_ar'] === '') {
return 'عنوان العرض بالعربي مطلوب';
}
if (empty($data['effective_from']) || empty($data['effective_to'])) {
return 'تاريخ البداية والانتهاء مطلوبان';
}
if ($data['effective_from'] > $data['effective_to']) {
return 'تاريخ البداية يجب أن يكون قبل تاريخ الانتهاء';
}
$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);
if (!$hasCash && !$hasInst) {
return 'يجب تحديد مسار واحد على الأقل (كاش أو تقسيط)';
}
if ($hasCash) {
if (!in_array($data['cash_discount_type'], ['percentage', 'fixed_amount'], true)) {
return 'نوع خصم الكاش غير صالح';
}
$val = (float) ($data['cash_discount_value'] ?? 0);
if ($val <= 0) {
return 'قيمة خصم الكاش يجب أن تكون أكبر من صفر';
}
if ($data['cash_discount_type'] === 'percentage' && $val > 100) {
return 'نسبة خصم الكاش لا يمكن أن تتجاوز 100%';
}
}
if ($hasInst) {
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_interest_rate'] !== null) {
$ir = (float) $data['inst_interest_rate'];
if ($ir < 0 || $ir > 100) {
return 'نسبة الفائدة يجب أن تكون بين 0 و 100';
}
}
if ($data['inst_grace_type'] !== null) {
if (!in_array($data['inst_grace_type'], ['first_n_months', 'full_free_under_n'], true)) {
return 'نوع فترة السماح غير صالح';
}
if ($data['inst_grace_months'] < 1) {
return 'عدد أشهر السماح يجب أن يكون 1 على الأقل';
}
if ($data['inst_grace_type'] === 'first_n_months' && $data['inst_months'] !== null && $data['inst_grace_months'] >= (int) $data['inst_months']) {
return 'أشهر السماح يجب أن تكون أقل من إجمالي عدد الأقساط';
}
}
}
$validAppliesTo = ['membership_fee', 'all'];
if (!in_array($data['applies_to'], $validAppliesTo, true)) {
return 'مجال التطبيق غير صالح';
}
return null;
}
private function getBranches(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT `id`, `name_ar` FROM `branches` WHERE `is_active` = 1 ORDER BY `name_ar`");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Models;
use App\Core\Model;
use App\Core\App;
class BoardOffer extends Model
{
protected static string $table = 'board_offers';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
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_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',
'created_by', 'updated_by',
];
public static function getActiveOffers(?int $branchId = null): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$where = "`is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?";
$params = [$today, $today];
if ($branchId !== null) {
$where .= " AND (`branch_id` IS NULL OR `branch_id` = ?)";
$params[] = $branchId;
}
return $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `branch_id` DESC, `effective_from` DESC",
$params
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['search'])) {
$where .= ' AND (`title_ar` LIKE ? OR `title_en` LIKE ? OR `board_decision_number` LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
$params[] = $s;
}
if (($filters['is_active'] ?? '') !== '') {
$where .= ' AND `is_active` = ?';
$params[] = (int) $filters['is_active'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `board_offers` WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$data = $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `effective_from` DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return [
'data' => $data,
'pagination' => \App\Core\Pagination::paginate($total, $perPage, $page),
];
}
}
...@@ -18,6 +18,14 @@ return [ ...@@ -18,6 +18,14 @@ return [
['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'], ['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'],
['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'], ['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'],
// Board Offers
['GET', '/pricing/board-offers', 'Pricing\Controllers\BoardOfferController@index', ['auth'], 'pricing.board_offers.view'],
['GET', '/pricing/board-offers/create', 'Pricing\Controllers\BoardOfferController@create', ['auth'], 'pricing.board_offers.create'],
['POST', '/pricing/board-offers', 'Pricing\Controllers\BoardOfferController@store', ['auth', 'csrf'], 'pricing.board_offers.create'],
['GET', '/pricing/board-offers/{id:\d+}/edit', 'Pricing\Controllers\BoardOfferController@edit', ['auth'], 'pricing.board_offers.edit'],
['POST', '/pricing/board-offers/{id:\d+}', 'Pricing\Controllers\BoardOfferController@update', ['auth', 'csrf'], 'pricing.board_offers.edit'],
['POST', '/pricing/board-offers/{id:\d+}/toggle', 'Pricing\Controllers\BoardOfferController@toggleActive', ['auth', 'csrf'], 'pricing.board_offers.edit'],
// Special Discounts // Special Discounts
['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'], ['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'],
['GET', '/pricing/special-discounts/create', 'Pricing\Controllers\SpecialDiscountController@create', ['auth'], 'pricing.special_discounts.create'], ['GET', '/pricing/special-discounts/create', 'Pricing\Controllers\SpecialDiscountController@create', ['auth'], 'pricing.special_discounts.create'],
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $offer ? 'تعديل عرض: ' . e($offer['title_ar']) : 'إضافة عرض مجلس إدارة جديد' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/pricing/board-offers" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$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']));
?>
<form method="POST" action="<?= $offer ? '/pricing/board-offers/' . (int) $offer['id'] : '/pricing/board-offers' ?>">
<?= csrf_field() ?>
<!-- Basic Info -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px;">
<i data-lucide="award" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات العرض</h3>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">عنوان العرض بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="title_ar" value="<?= $v('title_ar') ?>" class="form-input" required maxlength="200" placeholder="مثال: عرض الصيف 2026">
</div>
<div class="form-group">
<label class="form-label">عنوان العرض بالإنجليزي</label>
<input type="text" name="title_en" value="<?= $v('title_en') ?>" class="form-input" maxlength="200" placeholder="e.g. Summer 2026 Offer">
</div>
<div class="form-group">
<label class="form-label">رقم قرار مجلس الإدارة</label>
<input type="text" name="board_decision_number" value="<?= $v('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="<?= $v('board_decision_date') ?>" class="form-input" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">كل الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= ((int) ($offer['branch_id'] ?? 0)) === (int) $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;">اتركه فارغاً ليسري على كل الفروع</small>
</div>
<div class="form-group">
<label class="form-label">يُطبَّق على</label>
<select name="applies_to" class="form-select">
<option value="membership_fee" <?= $v('applies_to', 'membership_fee') === 'membership_fee' ? 'selected' : '' ?>>قيمة العضوية</option>
<option value="all" <?= $v('applies_to') === 'all' ? 'selected' : '' ?>>الكل</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:15px;">
<label class="form-label">وصف / ملاحظات</label>
<textarea name="description" class="form-input" rows="2" placeholder="وصف مختصر للعرض..."><?= $v('description') ?></textarea>
</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;">فترة السريان <span style="color:#DC2626;">*</span></h3>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ البداية <span style="color:#DC2626;">*</span></label>
<input type="date" name="effective_from" value="<?= $v('effective_from') ?>" class="form-input" required style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">تاريخ الانتهاء <span style="color:#DC2626;">*</span></label>
<input type="date" name="effective_to" value="<?= $v('effective_to') ?>" class="form-input" required style="direction:ltr;text-align:left;">
</div>
</div>
</div>
<!-- Cash Path -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="banknote" style="width:18px;height:18px;color:#059669;"></i>
<h3 style="margin:0;color:#059669;font-size:15px;">مسار الكاش (الدفع الفوري)</h3>
</div>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" name="has_cash_path" value="1" id="toggle_cash" <?= $hasCashPath ? 'checked' : '' ?> onchange="toggleSection('cash_section', this.checked)">
<span style="font-size:13px;color:#6B7280;">تفعيل</span>
</label>
</div>
<div id="cash_section" style="<?= $hasCashPath ? '' : 'display:none;' ?>">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">نوع الخصم</label>
<select name="cash_discount_type" class="form-select">
<option value="percentage" <?= $v('cash_discount_type') === 'percentage' ? 'selected' : '' ?>>نسبة مئوية (%)</option>
<option value="fixed_amount" <?= $v('cash_discount_type') === 'fixed_amount' ? 'selected' : '' ?>>مبلغ ثابت (ج.م)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">القيمة</label>
<input type="number" name="cash_discount_value" value="<?= $v('cash_discount_value') ?>" class="form-input" min="0.01" step="0.01" style="direction:ltr;text-align:left;" placeholder="مثال: 15">
<small style="color:#6B7280;font-size:11px;">نسبة أو مبلغ حسب النوع المختار</small>
</div>
</div>
</div>
</div>
<!-- Installment Path -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="layers" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">مسار التقسيط</h3>
</div>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" name="has_inst_path" value="1" id="toggle_inst" <?= $hasInstPath ? 'checked' : '' ?> onchange="toggleSection('inst_section', this.checked)">
<span style="font-size:13px;color:#6B7280;">تفعيل</span>
</label>
</div>
<div id="inst_section" style="<?= $hasInstPath ? '' : 'display:none;' ?>">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<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:#6B7280;font-size:11px;">اتركه فارغاً لاستخدام الافتراضي (25%)</small>
</div>
<div class="form-group">
<label class="form-label">عدد الأشهر</label>
<input type="number" name="inst_months" value="<?= $v('inst_months') ?>" class="form-input" min="1" max="120" style="direction:ltr;text-align:left;" placeholder="الافتراضي: 30">
<small style="color:#6B7280;font-size:11px;">اتركه فارغاً لاستخدام الافتراضي (30 شهر)</small>
</div>
<div class="form-group">
<label class="form-label">نسبة الفائدة السنوية (%)</label>
<input type="number" name="inst_interest_rate" value="<?= $v('inst_interest_rate') ?>" class="form-input" min="0" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="الافتراضي: 22%">
<small style="color:#6B7280;font-size:11px;">اتركه فارغاً لاستخدام الافتراضي (22%)</small>
</div>
</div>
<!-- Grace Period -->
<div style="margin-top:20px;padding-top:15px;border-top:1px solid #E5E7EB;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:15px;">
<i data-lucide="clock" style="width:16px;height:16px;color:#7C3AED;"></i>
<h4 style="margin:0;color:#7C3AED;font-size:14px;">فترة السماح (اختياري)</h4>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">نوع فترة السماح</label>
<select name="inst_grace_type" id="grace_type_select" class="form-select" onchange="toggleGraceDetails()">
<option value="" <?= !$v('inst_grace_type') ? 'selected' : '' ?>>بدون فترة سماح</option>
<option value="first_n_months" <?= $v('inst_grace_type') === 'first_n_months' ? 'selected' : '' ?>>أول N شهر بدون فوائد</option>
<option value="full_free_under_n" <?= $v('inst_grace_type') === 'full_free_under_n' ? 'selected' : '' ?>>إعفاء كامل إذا الأقساط ≤ N شهر</option>
</select>
</div>
<div class="form-group" id="grace_months_group" style="<?= $v('inst_grace_type') ? '' : 'display:none;' ?>">
<label class="form-label">عدد أشهر السماح</label>
<input type="number" name="inst_grace_months" value="<?= $v('inst_grace_months', 0) ?>" class="form-input" min="1" max="60" style="direction:ltr;text-align:left;" placeholder="مثال: 6">
</div>
<div class="form-group" id="post_grace_rate_group" style="<?= $v('inst_grace_type') === 'first_n_months' ? '' : 'display:none;' ?>">
<label class="form-label">فائدة ما بعد السماح (%)</label>
<input type="number" name="inst_post_grace_rate" value="<?= $v('inst_post_grace_rate') ?>" class="form-input" min="0" max="100" step="0.01" style="direction:ltr;text-align:left;" placeholder="اتركه فارغاً لنفس الفائدة أعلاه">
<small style="color:#6B7280;font-size:11px;">فارغ = نفس نسبة الفائدة الأساسية</small>
</div>
</div>
</div>
</div>
</div>
<!-- Notes -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div class="form-group" style="margin:0;">
<label class="form-label">ملاحظات إضافية</label>
<textarea name="notes" class="form-input" rows="2"><?= $v('notes') ?></textarea>
</div>
</div>
<?php if ($offer): ?>
<div class="card" style="padding:15px;margin-bottom:20px;background:#F9FAFB;">
<div class="form-group" style="margin:0;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" name="is_active" value="1" <?= $offer['is_active'] ? 'checked' : '' ?>>
<span class="form-label" style="margin:0;">العرض مفعّل</span>
</label>
</div>
</div>
<?php endif; ?>
<!-- Submit -->
<div style="display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary" style="min-width:140px;">
<i data-lucide="check" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i>
<?= $offer ? 'تحديث العرض' : 'حفظ العرض' ?>
</button>
<a href="/pricing/board-offers" class="btn btn-outline">إلغاء</a>
</div>
</form>
<script>
function toggleSection(id, show) {
document.getElementById(id).style.display = show ? '' : 'none';
}
function toggleGraceDetails() {
var val = document.getElementById('grace_type_select').value;
document.getElementById('grace_months_group').style.display = val ? '' : 'none';
document.getElementById('post_grace_rate_group').style.display = (val === 'first_n_months') ? '' : 'none';
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>عروض مجلس الإدارة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/pricing/board-offers/create" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="plus" style="width:15px;height:15px;"></i> إضافة عرض جديد
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="padding:15px;margin-bottom:20px;">
<form method="GET" action="/pricing/board-offers" style="display:flex;gap:12px;align-items:end;flex-wrap:wrap;">
<div class="form-group" style="margin:0;flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" class="form-input" placeholder="عنوان العرض أو رقم القرار...">
</div>
<div class="form-group" style="margin:0;width:150px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="is_active" class="form-select">
<option value="">الكل</option>
<option value="1" <?= ($filters['is_active'] ?? '') === '1' ? 'selected' : '' ?>>مفعّل</option>
<option value="0" <?= ($filters['is_active'] ?? '') === '0' ? 'selected' : '' ?>>معطّل</option>
</select>
</div>
<button type="submit" class="btn btn-outline" style="height:38px;">
<i data-lucide="search" style="width:14px;height:14px;"></i>
</button>
</form>
</div>
<!-- Table -->
<div class="card" style="overflow-x:auto;">
<table class="table">
<thead>
<tr>
<th>العرض</th>
<th>مسار الكاش</th>
<th>مسار التقسيط</th>
<th>الفرع</th>
<th>الفترة</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:40px;">لا توجد عروض حالياً</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td>
<strong style="color:#1F2937;"><?= e($row['title_ar']) ?></strong>
<?php if ($row['board_decision_number']): ?>
<br><small style="color:#6B7280;">قرار رقم: <?= e($row['board_decision_number']) ?></small>
<?php endif; ?>
</td>
<td>
<?php if ($row['cash_discount_type']): ?>
<span style="background:#ECFDF5;color:#065F46;padding:2px 8px;border-radius:4px;font-size:12px;">
<?php if ($row['cash_discount_type'] === 'percentage'): ?>
<?= e($row['cash_discount_value']) ?>%
<?php else: ?>
<?= money($row['cash_discount_value']) ?>
<?php endif; ?>
</span>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td>
<?php if ($row['inst_months'] || $row['inst_interest_rate'] !== null || $row['inst_down_payment_pct'] !== null): ?>
<div style="font-size:12px;line-height:1.6;">
<?php if ($row['inst_down_payment_pct'] !== null): ?>
<span style="background:#EFF6FF;color:#1E40AF;padding:1px 6px;border-radius:3px;">مقدم <?= e($row['inst_down_payment_pct']) ?>%</span>
<?php endif; ?>
<?php if ($row['inst_months']): ?>
<span style="background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:3px;"><?= (int) $row['inst_months'] ?> شهر</span>
<?php endif; ?>
<?php if ($row['inst_interest_rate'] !== null): ?>
<span style="background:#FEE2E2;color:#991B1B;padding:1px 6px;border-radius:3px;">فائدة <?= e($row['inst_interest_rate']) ?>%</span>
<?php endif; ?>
<?php if ($row['inst_grace_months'] > 0): ?>
<br><span style="background:#F3E8FF;color:#6B21A8;padding:1px 6px;border-radius:3px;margin-top:3px;display:inline-block;">
<?= (int) $row['inst_grace_months'] ?> شهر سماح
<?= $row['inst_grace_type'] === 'full_free_under_n' ? '(إعفاء كامل)' : '' ?>
</span>
<?php endif; ?>
</div>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td>
<?php if ($row['branch_id']): ?>
<span style="font-size:12px;"><?= e($row['branch_name'] ?? 'فرع #' . $row['branch_id']) ?></span>
<?php else: ?>
<span style="color:#6B7280;font-size:12px;">كل الفروع</span>
<?php endif; ?>
</td>
<td style="font-size:12px;white-space:nowrap;">
<?= e($row['effective_from']) ?><br>
<span style="color:#6B7280;">إلى</span> <?= e($row['effective_to']) ?>
</td>
<td>
<?php
$today = date('Y-m-d');
$isExpired = $row['effective_to'] < $today;
$isActive = $row['is_active'] && !$isExpired;
?>
<?php if ($isActive): ?>
<span style="background:#D1FAE5;color:#065F46;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">مفعّل</span>
<?php elseif ($isExpired): ?>
<span style="background:#FEE2E2;color:#991B1B;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">منتهي</span>
<?php else: ?>
<span style="background:#F3F4F6;color:#6B7280;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">معطّل</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<a href="/pricing/board-offers/<?= (int) $row['id'] ?>/edit" class="btn btn-sm btn-outline" title="تعديل">
<i data-lucide="pencil" style="width:13px;height:13px;"></i>
</a>
<form method="POST" action="/pricing/board-offers/<?= (int) $row['id'] ?>/toggle" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" title="<?= $row['is_active'] ? 'تعطيل' : 'تفعيل' ?>">
<i data-lucide="<?= $row['is_active'] ? 'pause' : 'play' ?>" style="width:13px;height:13px;"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (!empty($pagination) && $pagination['total_pages'] > 1): ?>
<div style="display:flex;justify-content:center;gap:6px;margin-top:20px;">
<?php for ($p = 1; $p <= $pagination['total_pages']; $p++): ?>
<a href="?page=<?= $p ?>&q=<?= urlencode($filters['search'] ?? '') ?>&is_active=<?= urlencode($filters['is_active'] ?? '') ?>"
class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>">
<?= $p ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
...@@ -14,17 +14,21 @@ MenuRegistry::register('pricing', [ ...@@ -14,17 +14,21 @@ MenuRegistry::register('pricing', [
'parent' => null, 'parent' => null,
'order' => 850, 'order' => 850,
'children' => [ 'children' => [
['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1], ['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 2], ['label_ar' => 'عروض مجلس الإدارة', 'label_en' => 'Board Offers', 'route' => '/pricing/board-offers', 'permission' => 'pricing.board_offers.view', 'order' => 2],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 3],
], ],
]); ]);
PermissionRegistry::register('pricing', [ PermissionRegistry::register('pricing', [
'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'], 'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'],
'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'], 'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'],
'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'], 'pricing.board_offers.view' => ['ar' => 'عرض عروض مجلس الإدارة', 'en' => 'View Board Offers'],
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'], 'pricing.board_offers.create' => ['ar' => 'إنشاء عرض مجلس إدارة', 'en' => 'Create Board Offer'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'], 'pricing.board_offers.edit' => ['ar' => 'تعديل عروض مجلس الإدارة', 'en' => 'Edit Board Offers'],
'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'],
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة', 'en' => 'Edit Special Discounts'],
]); ]);
// When a member is activated, check if their discount includes free subscription bonus // When a member is activated, check if their discount includes free subscription bonus
......
<?php
declare(strict_types=1);
return [
'up' => "
DROP TABLE IF EXISTS `board_offers`;
CREATE TABLE `board_offers` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`title_ar` VARCHAR(200) NOT NULL,
`title_en` VARCHAR(200) NULL,
`description` TEXT NULL,
`cash_discount_type` ENUM('percentage','fixed_amount') NULL,
`cash_discount_value` DECIMAL(15,2) NULL,
`inst_down_payment_pct` DECIMAL(5,2) NULL,
`inst_months` INT NULL,
`inst_interest_rate` DECIMAL(5,2) NULL,
`inst_grace_type` ENUM('first_n_months','full_free_under_n') NULL,
`inst_grace_months` INT NOT NULL DEFAULT 0,
`inst_post_grace_rate` DECIMAL(5,2) NULL,
`branch_id` BIGINT UNSIGNED NULL,
`applies_to` ENUM('membership_fee','all') NOT NULL DEFAULT 'membership_fee',
`effective_from` DATE NOT NULL,
`effective_to` DATE NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`board_decision_number` VARCHAR(50) NULL,
`board_decision_date` DATE NULL,
`notes` TEXT NULL,
`created_by` INT UNSIGNED NULL,
`updated_by` INT UNSIGNED NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `board_offers`",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `payment_requests`
ADD COLUMN `board_offer_id` INT UNSIGNED NULL,
ADD COLUMN `offer_snapshot_json` TEXT NULL",
'down' => "
ALTER TABLE `payment_requests`
DROP COLUMN `offer_snapshot_json`,
DROP COLUMN `board_offer_id`",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `installment_plans`
ADD COLUMN `board_offer_id` INT UNSIGNED NULL,
ADD COLUMN `grace_months` INT NULL DEFAULT 0,
ADD COLUMN `post_grace_interest_rate` DECIMAL(5,2) NULL",
'down' => "
ALTER TABLE `installment_plans`
DROP COLUMN `post_grace_interest_rate`,
DROP COLUMN `grace_months`,
DROP COLUMN `board_offer_id`",
];
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