Commit cb63de72 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(pricing+subscriptions): multi-tier board offers + fix first-year subscription logic

Board Offers:
- Add board_offer_tiers table for multiple payment plans per offer
- Each offer now supports 4 tiers: cash (10% discount), 24mo/0%, 40mo/15%, 60mo/15%
- View shows all tiers as selectable cards with pre-calculated breakdowns
- Controller accepts offer_tier_id and uses tier-specific terms
- Seed populates tiers for all active board offers
- Legacy fallback preserved when no tiers are configured

Subscription Fix:
- First-year members no longer get subscription rows (membership fee covers current FY)
- First-year dependents no longer get subscription rows (addition fee covers current FY)
- MembershipValidationService bypasses subscription check for first-year members
- AutoFreezeService.checkSubscriptionBlock bypasses for first-year members
- MembershipRulesService.canPrintCarnet bypasses for first-year members
- SubscriptionGenerator skips first-year members/dependents entirely
- Individual subscription pay() now redirects to payYear() (all-or-nothing family payment)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1879dcb6
...@@ -372,6 +372,16 @@ class MemberController extends Controller ...@@ -372,6 +372,16 @@ class MemberController extends Controller
$cashDiscount = $bestOffer ? BoardOfferService::getCashDiscount($bill['total_pending'], $bestOffer) : null; $cashDiscount = $bestOffer ? BoardOfferService::getCashDiscount($bill['total_pending'], $bestOffer) : null;
$installmentTerms = BoardOfferService::getInstallmentTerms($bestOffer); $installmentTerms = BoardOfferService::getInstallmentTerms($bestOffer);
// Load offer tiers with pre-calculated breakdowns
$offerTiers = [];
if ($bestOffer) {
$tiers = BoardOfferService::getOfferTiers((int) $bestOffer['id']);
foreach ($tiers as $tier) {
$tier['breakdown'] = BoardOfferService::calculateTierBreakdown($tier, $bill['total_pending']);
$offerTiers[] = $tier;
}
}
// Death cases for dependents (keyed by type:id) // Death cases for dependents (keyed by type:id)
$dependentDeathCases = []; $dependentDeathCases = [];
$depDeaths = $db->select( $depDeaths = $db->select(
...@@ -414,6 +424,7 @@ class MemberController extends Controller ...@@ -414,6 +424,7 @@ class MemberController extends Controller
'bestOffer' => $bestOffer, 'bestOffer' => $bestOffer,
'cashDiscount' => $cashDiscount, 'cashDiscount' => $cashDiscount,
'installmentTerms' => $installmentTerms, 'installmentTerms' => $installmentTerms,
'offerTiers' => $offerTiers,
'subscriptionStatus' => $subscriptionStatus, 'subscriptionStatus' => $subscriptionStatus,
'overdueSubscriptions' => $overdueSubscriptions, 'overdueSubscriptions' => $overdueSubscriptions,
'transferFeePayment' => $transferFeePayment, 'transferFeePayment' => $transferFeePayment,
...@@ -510,18 +521,30 @@ class MemberController extends Controller ...@@ -510,18 +521,30 @@ 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('المبلغ غير صالح');
// Resolve active board offer (if any) at request time // Resolve active board offer and tier at request time
$offerId = (int) $request->post('board_offer_id', 0); $offerId = (int) $request->post('board_offer_id', 0);
$tierId = (int) $request->post('offer_tier_id', 0);
$activeOffer = null; $activeOffer = null;
$selectedTier = null;
if ($offerId > 0) { 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')]); $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) { if (!$activeOffer) {
$activeOffer = BoardOfferService::getBestOffer((int) ($member->branch_id ?? 0) ?: null); $activeOffer = BoardOfferService::getBestOffer((int) ($member->branch_id ?? 0) ?: null);
} }
$offerSnapshot = $activeOffer ? BoardOfferService::snapshotOffer($activeOffer) : null;
$instTerms = BoardOfferService::getInstallmentTerms($activeOffer); // Resolve selected tier (overrides legacy single-config installment terms)
if ($tierId > 0) {
$selectedTier = BoardOfferService::getTierById($tierId);
}
$offerSnapshot = $activeOffer ? BoardOfferService::snapshotOffer($activeOffer, $selectedTier) : null;
// Determine installment terms from tier or offer
$instTerms = $selectedTier
? BoardOfferService::getInstallmentTermsForTier($selectedTier)
: BoardOfferService::getInstallmentTerms($activeOffer);
$instMaxMonths = $instTerms['max_months']; $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;
...@@ -544,12 +567,19 @@ class MemberController extends Controller ...@@ -544,12 +567,19 @@ 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) { if ($selectedTier) {
$breakdown[] = '🎯 العرض: ' . ($selectedTier['tier_name_ar'] ?? '');
} elseif ($activeOffer) {
$breakdown[] = '🎯 عرض مجلس الإدارة: ' . ($activeOffer['title_ar'] ?? ''); $breakdown[] = '🎯 عرض مجلس الإدارة: ' . ($activeOffer['title_ar'] ?? '');
} }
} else { } else {
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount); $breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount);
if ($activeOffer && $activeOffer['cash_discount_type']) { if ($selectedTier && $selectedTier['payment_type'] === 'cash') {
$tierBreakdown = BoardOfferService::calculateTierBreakdown($selectedTier, $bill['total_pending']);
if (bccomp($tierBreakdown['savings'], '0', 2) > 0) {
$breakdown[] = '🎯 خصم ' . $selectedTier['tier_name_ar'] . ': ' . money($tierBreakdown['savings']);
}
} elseif ($activeOffer && $activeOffer['cash_discount_type']) {
$cashInfo = BoardOfferService::getCashDiscount($bill['total_pending'], $activeOffer); $cashInfo = BoardOfferService::getCashDiscount($bill['total_pending'], $activeOffer);
if (bccomp($cashInfo['savings'], '0', 2) > 0) { if (bccomp($cashInfo['savings'], '0', 2) > 0) {
$breakdown[] = '🎯 خصم عرض مجلس الإدارة: ' . money($cashInfo['savings']); $breakdown[] = '🎯 خصم عرض مجلس الإدارة: ' . money($cashInfo['savings']);
......
...@@ -72,6 +72,15 @@ final class AutoFreezeService ...@@ -72,6 +72,15 @@ final class AutoFreezeService
$financialYear = self::getCurrentFinancialYear(); $financialYear = self::getCurrentFinancialYear();
// First-year members: membership fee covers the current FY
$activatedAt = $db->selectOne("SELECT activated_at FROM members WHERE id = ? LIMIT 1", [$memberId]);
if ($activatedAt && $activatedAt['activated_at']) {
$fyStartYear = (int) (explode('/', $financialYear)[0] ?? date('Y'));
if ($activatedAt['activated_at'] >= $fyStartYear . '-07-01') {
return ['blocked' => false];
}
}
$unpaid = $db->select( $unpaid = $db->select(
"SELECT total_amount, paid_amount FROM subscriptions "SELECT total_amount, paid_amount FROM subscriptions
WHERE member_id = ? WHERE member_id = ?
......
...@@ -33,10 +33,94 @@ final class BoardOfferService ...@@ -33,10 +33,94 @@ final class BoardOfferService
if (empty($offers)) { if (empty($offers)) {
return null; return null;
} }
// Branch-specific first (branch_id DESC puts non-null first)
return $offers[0]; return $offers[0];
} }
public static function getOfferTiers(int $offerId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM `board_offer_tiers` WHERE `board_offer_id` = ? AND `is_active` = 1 ORDER BY `tier_order` ASC",
[$offerId]
);
}
public static function getTierById(int $tierId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT t.*, o.is_active AS offer_active, o.effective_from, o.effective_to, o.title_ar AS offer_title
FROM `board_offer_tiers` t
JOIN `board_offers` o ON o.id = t.board_offer_id
WHERE t.id = ? AND t.is_active = 1",
[$tierId]
) ?: null;
}
public static function calculateTierBreakdown(array $tier, string $totalAmount): array
{
if ($tier['payment_type'] === 'cash') {
$discountPct = (string) ($tier['cash_discount_pct'] ?? '0');
$savings = bcdiv(bcmul($totalAmount, $discountPct, 4), '100', 2);
$afterDiscount = bcsub($totalAmount, $savings, 2);
return [
'payment_type' => 'cash',
'original' => $totalAmount,
'discount_pct' => $discountPct,
'savings' => $savings,
'amount_due' => $afterDiscount,
'down_payment' => $afterDiscount,
'months' => 0,
'interest_rate' => '0.00',
'interest_total' => '0.00',
'monthly' => '0.00',
'grand_total' => $afterDiscount,
];
}
$downPct = (string) ($tier['down_payment_pct'] ?? '25');
$months = (int) ($tier['months'] ?? 24);
$interestRate = (string) ($tier['interest_rate'] ?? '0');
$downPayment = bcdiv(bcmul($totalAmount, $downPct, 4), '100', 2);
$remaining = bcsub($totalAmount, $downPayment, 2);
$interestTotal = bcmul(
bcmul($remaining, bcdiv($interestRate, '100', 8), 8),
bcdiv((string) $months, '12', 8),
2
);
$totalWithInterest = bcadd($remaining, $interestTotal, 2);
$monthly = $months > 0 ? bcdiv($totalWithInterest, (string) $months, 2) : '0.00';
$grandTotal = bcadd($downPayment, $totalWithInterest, 2);
return [
'payment_type' => 'installment',
'original' => $totalAmount,
'discount_pct' => '0.00',
'savings' => '0.00',
'amount_due' => $totalAmount,
'down_payment' => $downPayment,
'down_pct' => $downPct,
'months' => $months,
'interest_rate' => $interestRate,
'interest_total' => $interestTotal,
'remaining' => $remaining,
'monthly' => $monthly,
'grand_total' => $grandTotal,
];
}
public static function getInstallmentTermsForTier(array $tier): array
{
return [
'down_pct' => (string) ($tier['down_payment_pct'] ?? '25.00'),
'max_months' => (int) ($tier['months'] ?? 24),
'interest_rate' => (string) ($tier['interest_rate'] ?? '0.00'),
'grace_type' => null,
'grace_months' => 0,
];
}
public static function getCashDiscount(string $amount, ?array $offer = null): array public static function getCashDiscount(string $amount, ?array $offer = null): array
{ {
if ($offer === null) { if ($offer === null) {
...@@ -151,9 +235,9 @@ final class BoardOfferService ...@@ -151,9 +235,9 @@ final class BoardOfferService
return $overrides; return $overrides;
} }
public static function snapshotOffer(array $offer): string public static function snapshotOffer(array $offer, ?array $tier = null): string
{ {
return json_encode([ $data = [
'id' => $offer['id'], 'id' => $offer['id'],
'title_ar' => $offer['title_ar'], 'title_ar' => $offer['title_ar'],
'cash_discount_type' => $offer['cash_discount_type'], 'cash_discount_type' => $offer['cash_discount_type'],
...@@ -166,7 +250,22 @@ final class BoardOfferService ...@@ -166,7 +250,22 @@ final class BoardOfferService
'inst_post_grace_rate' => $offer['inst_post_grace_rate'], 'inst_post_grace_rate' => $offer['inst_post_grace_rate'],
'effective_from' => $offer['effective_from'], 'effective_from' => $offer['effective_from'],
'effective_to' => $offer['effective_to'], 'effective_to' => $offer['effective_to'],
], JSON_UNESCAPED_UNICODE); ];
if ($tier) {
$data['tier'] = [
'id' => $tier['id'],
'tier_order' => $tier['tier_order'],
'tier_name_ar' => $tier['tier_name_ar'],
'payment_type' => $tier['payment_type'],
'cash_discount_pct' => $tier['cash_discount_pct'],
'down_payment_pct' => $tier['down_payment_pct'],
'months' => $tier['months'],
'interest_rate' => $tier['interest_rate'],
];
}
return json_encode($data, JSON_UNESCAPED_UNICODE);
} }
public static function restoreFromSnapshot(string $json): ?array public static function restoreFromSnapshot(string $json): ?array
......
...@@ -1077,9 +1077,8 @@ final class MembershipRulesService ...@@ -1077,9 +1077,8 @@ final class MembershipRulesService
$currentYear = self::currentFinancialYear(); $currentYear = self::currentFinancialYear();
// Check member status
$member = $db->selectOne( $member = $db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0", "SELECT status, activated_at FROM members WHERE id = ? AND is_archived = 0",
[$memberId] [$memberId]
); );
...@@ -1091,14 +1090,24 @@ final class MembershipRulesService ...@@ -1091,14 +1090,24 @@ final class MembershipRulesService
return ['allowed' => false, 'reason' => 'العضوية غير فعالة (حالة: ' . $member['status'] . ')']; return ['allowed' => false, 'reason' => 'العضوية غير فعالة (حالة: ' . $member['status'] . ')'];
} }
// Check annual subscription paid // First-year members: membership fee covers the current FY
$paid = $db->selectOne( $firstYearCovered = false;
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'annual_subscription' AND financial_year = ? AND is_voided = 0 LIMIT 1", if (!empty($member['activated_at'])) {
[$memberId, $currentYear] $fyStartYear = (int) (explode('/', $currentYear)[0] ?? date('Y'));
); if ($member['activated_at'] >= $fyStartYear . '-07-01') {
$firstYearCovered = true;
}
}
if (!$paid) { if (!$firstYearCovered) {
return ['allowed' => false, 'reason' => 'لم يتم سداد الاشتراك السنوي للعام ' . $currentYear]; $paid = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'annual_subscription' AND financial_year = ? AND is_voided = 0 LIMIT 1",
[$memberId, $currentYear]
);
if (!$paid) {
return ['allowed' => false, 'reason' => 'لم يتم سداد الاشتراك السنوي للعام ' . $currentYear];
}
} }
// Check outstanding installments // Check outstanding installments
......
...@@ -95,6 +95,24 @@ final class MembershipValidationService ...@@ -95,6 +95,24 @@ final class MembershipValidationService
} }
$fy = financial_year(); $fy = financial_year();
// First-year members: membership fee covers the current FY — no subscription row needed
$activatedAt = $db->selectOne(
"SELECT activated_at FROM members WHERE id = ? LIMIT 1",
[(int) $member['id']]
);
if ($activatedAt && $activatedAt['activated_at']) {
$fyStartYear = (int) (explode('/', $fy)[0] ?? date('Y'));
$fyStartDate = $fyStartYear . '-07-01';
if ($activatedAt['activated_at'] >= $fyStartDate) {
return $base + [
'is_active_member' => true,
'effective_type' => 'member',
'reason' => null,
];
}
}
$subscription = $db->selectOne( $subscription = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status = 'paid' LIMIT 1", "SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status = 'paid' LIMIT 1",
[(int) $member['id'], $fy] [(int) $member['id'], $fy]
......
...@@ -499,6 +499,65 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -499,6 +499,65 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($offerTiers)): ?>
<!-- ═══ Multi-Tier Payment Options ═══ -->
<div style="display:grid;grid-template-columns:repeat(<?= min(count($offerTiers), 4) ?>, 1fr);gap:12px;">
<?php foreach ($offerTiers as $tierIdx => $tier):
$bd = $tier['breakdown'];
$isCash = $bd['payment_type'] === 'cash';
$borderColor = $isCash ? '#059669' : '#0284C7';
$headerColor = $isCash ? '#059669' : '#0284C7';
?>
<div style="background:#fff;border:2px solid <?= $borderColor ?>;border-radius:12px;padding:16px;display:flex;flex-direction:column;">
<div style="text-align:center;margin-bottom:10px;">
<div style="font-size:11px;color:#6B7280;margin-bottom:2px;">العرض <?= (int) $tier['tier_order'] ?></div>
<h5 style="margin:0;color:<?= $headerColor ?>;font-size:13px;"><?= e($tier['tier_name_ar']) ?></h5>
</div>
<?php if ($isCash): ?>
<div style="text-align:center;flex:1;">
<div style="font-size:12px;color:#6B7280;text-decoration:line-through;"><?= money($bd['original']) ?></div>
<div style="font-size:22px;font-weight:700;color:#059669;margin:4px 0;"><?= money($bd['amount_due']) ?></div>
<div style="font-size:11px;color:#059669;background:#ECFDF5;display:inline-block;padding:2px 8px;border-radius:4px;">&#x2714; وفّرت <?= money($bd['savings']) ?></div>
</div>
<?php else: ?>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:8px 10px;font-size:11px;flex:1;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="color:#6B7280;padding:2px 0;">المقدم (<?= (float) $bd['down_pct'] ?>%)</td><td style="text-align:left;font-weight:600;color:#059669;"><?= money($bd['down_payment']) ?></td></tr>
<tr><td style="color:#6B7280;padding:2px 0;">المتبقي</td><td style="text-align:left;font-weight:600;"><?= money($bd['remaining']) ?></td></tr>
<?php if (bccomp($bd['interest_rate'], '0', 2) > 0): ?>
<tr><td style="color:#6B7280;padding:2px 0;">الفائدة (<?= (float) $bd['interest_rate'] ?>%)</td><td style="text-align:left;color:#D97706;font-weight:600;"><?= money($bd['interest_total']) ?></td></tr>
<?php else: ?>
<tr><td style="color:#7C3AED;padding:2px 0;">&#x1f389; بدون فوائد</td><td style="text-align:left;color:#7C3AED;font-weight:600;">0 ج.م</td></tr>
<?php endif; ?>
<tr style="border-top:1px solid #BFDBFE;"><td style="color:#374151;font-weight:700;padding:3px 0;">القسط الشهري</td><td style="text-align:left;font-weight:800;color:#0284C7;font-size:13px;"><?= money($bd['monthly']) ?></td></tr>
<tr><td style="color:#6B7280;padding:2px 0;">الإجمالي</td><td style="text-align:left;font-weight:600;"><?= money($bd['grand_total']) ?></td></tr>
</table>
</div>
<?php endif; ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership" style="margin-top:10px;">
<?= csrf_field() ?>
<input type="hidden" name="payment_type" value="<?= $isCash ? e($paymentTypeForType) : 'down_payment' ?>">
<input type="hidden" name="amount" value="<?= e($isCash ? $bd['amount_due'] : $bd['down_payment']) ?>">
<input type="hidden" name="board_offer_id" value="<?= (int) $bestOffer['id'] ?>">
<input type="hidden" name="offer_tier_id" value="<?= (int) $tier['id'] ?>">
<?php if (!$isCash): ?>
<input type="hidden" name="installment_months" value="<?= (int) $bd['months'] ?>">
<?php endif; ?>
<button type="submit" class="btn btn-primary"
style="width:100%;background:#D97706;border-color:#D97706;font-size:12px;padding:8px;"
onclick="return confirm('إرسال طلب دفع <?= $isCash ? money($bd['amount_due']) : money($bd['down_payment']) . ' مقدم + ' . $bd['months'] . ' شهر' ?> للخزينة؟')">
&#x1f4e4; إرسال للخزينة
</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<!-- ═══ Legacy Fallback (no tiers configured) ═══ -->
<?php <?php
$cashAmount = $bill['total_pending']; $cashAmount = $bill['total_pending'];
$cashSavings = '0.00'; $cashSavings = '0.00';
...@@ -508,7 +567,6 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -508,7 +567,6 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
} }
?> ?>
<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 -->
<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>
...@@ -523,9 +581,7 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -523,9 +581,7 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
<?= 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($cashAmount) ?>"> <input type="hidden" name="amount" value="<?= e($cashAmount) ?>">
<?php if (!empty($bestOffer)): ?> <?php if (!empty($bestOffer)): ?><input type="hidden" name="board_offer_id" value="<?= (int) $bestOffer['id'] ?>"><?php endif; ?>
<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> <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>
...@@ -539,122 +595,67 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -539,122 +595,67 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
$minDown = bcdiv(bcmul($bill['total_pending'], (string) $instDownPct, 2), '100', 2); $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) { if ($instGraceType === 'full_free_under_n' && $instMaxMos <= $instGraceMonths) {
$instInterest = '0.00'; $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 -->
<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;">مقدم <?= $instDownPct ?>% على الأقل + باقي على أقساط</p> <p style="font-size:12px;color:#6B7280;margin:0 0 2px;">مقدم <?= $instDownPct ?>% + باقي على أقساط</p>
<p style="font-size:11px;color:#D97706;margin:0 0 4px;">فائدة <?= $instRate ?>% سنوياً — حتى <?= $instMaxMos ?> شهر</p> <p style="font-size:11px;color:#D97706;margin:0 0 12px;">فائدة <?= $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) -->
<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;">
<table style="width:100%;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;">
<tr> <tr><td style="color:#6B7280;padding:3px 0;">المقدم</td><td style="text-align:left;font-weight:600;color:#059669;" id="inst_down_disp"><?= money($minDown) ?></td></tr>
<td style="color:#6B7280;padding:3px 0;">سعر العضوية</td> <tr><td style="color:#374151;font-weight:700;padding:4px 0;">المتبقي</td><td style="text-align:left;font-weight:800;color:#0284C7;" id="inst_remaining_disp"><?= money($instRem) ?></td></tr>
<td style="text-align:left;font-weight:600;" id="inst_price_disp"><?= money($bill['total_pending']) ?></td> <tr><td style="color:#6B7280;padding:3px 0;" id="inst_interest_label">الفائدة (<?= (float) $instRate ?>% × <?= $instMaxMos ?> شهر)</td><td style="text-align:left;color:#D97706;font-weight:600;" id="inst_interest_disp"><?= money($instInterest) ?></td></tr>
</tr> <tr style="border-top:2px solid #93C5FD;"><td style="font-weight:700;color:#0D7377;padding:5px 0;" id="inst_monthly_label">القسط الشهري (÷ <?= $instMaxMos ?>)</td><td style="text-align:left;font-weight:900;color:#0D7377;font-size:16px;" id="inst_monthly_disp"><?= money($instMonthly) ?></td></tr>
<tr>
<td style="color:#6B7280;padding:3px 0;">المقدم</td>
<td style="text-align:left;font-weight:600;color:#059669;" id="inst_down_disp"><?= money($minDown) ?></td>
</tr>
<tr style="border-top:1px solid #BFDBFE;">
<td style="color:#374151;font-weight:700;padding:4px 0;">المبلغ المتبقي</td>
<td style="text-align:left;font-weight:800;color:#0284C7;" id="inst_remaining_disp"><?= money($instRem) ?></td>
</tr>
<tr>
<td style="color:#6B7280;padding:3px 0;" id="inst_interest_label">الفائدة (<?= (float) $instRate ?>% × <?= $instMaxMos ?> شهر)</td>
<td style="text-align:left;color:#D97706;font-weight:600;" id="inst_interest_disp"><?= money($instInterest) ?></td>
</tr>
<tr>
<td style="color:#6B7280;padding:3px 0;">الإجمالي مع الفائدة</td>
<td style="text-align:left;font-weight:600;" id="inst_totalwi_disp"><?= money($instTotalWI) ?></td>
</tr>
<tr style="border-top:2px solid #93C5FD;">
<td style="font-weight:700;color:#0D7377;padding:5px 0;" id="inst_monthly_label">القسط الشهري (÷ <?= $instMaxMos ?>)</td>
<td style="text-align:left;font-weight:900;color:#0D7377;font-size:16px;" id="inst_monthly_disp"><?= money($instMonthly) ?></td>
</tr>
</table> </table>
</div> </div>
<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)): ?> <?php if (!empty($bestOffer)): ?><input type="hidden" name="board_offer_id" value="<?= (int) $bestOffer['id'] ?>"><?php endif; ?>
<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" value="<?= e($minDown) ?>" min="<?= e($minDown) ?>" step="0.01" class="form-input" style="direction:ltr;text-align:left;" oninput="instRecalc()" required>
value="<?= e($minDown) ?>" min="<?= e($minDown) ?>" step="0.01"
class="form-input" style="direction:ltr;text-align:left;"
oninput="instRecalc()" required>
</div> </div>
<div class="form-group" style="margin-bottom:12px;"> <div class="form-group" style="margin-bottom:12px;">
<label class="form-label" style="font-size:12px;">عدد الأشهر (1 – <?= $instMaxMos ?>)</label> <label class="form-label" style="font-size:12px;">عدد الأشهر (1 – <?= $instMaxMos ?>)</label>
<input type="number" id="inst_months_input" name="installment_months" <input type="number" id="inst_months_input" name="installment_months" value="<?= $instMaxMos ?>" min="1" max="<?= $instMaxMos ?>" class="form-input" style="direction:ltr;text-align:left;" oninput="instRecalc()">
value="<?= $instMaxMos ?>" min="1" max="<?= $instMaxMos ?>"
class="form-input" style="direction:ltr;text-align:left;"
oninput="instRecalc()">
</div> </div>
<button type="submit" class="btn btn-primary" <button type="submit" class="btn btn-primary" style="width:100%;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب تقسيط للخزينة؟')">&#x1f4e4; إرسال للخزينة</button>
style="width:100%;background:#D97706;border-color:#D97706;"
onclick="return confirm('إرسال طلب تقسيط للخزينة؟')">
&#x1f4e4; إرسال للخزينة
</button>
</form> </form>
</div> </div>
<script> <script>
(function () { (function () {
var TOTAL = <?= json_encode((float) $bill['total_pending']) ?>; var TOTAL = <?= json_encode((float) $bill['total_pending']) ?>, RATE = <?= json_encode((float) $instRate) ?>, MIN_DOWN = <?= json_encode((float) $minDown) ?>;
var RATE = <?= json_encode((float) $instRate) ?>; var GRACE_TYPE = <?= json_encode($instGraceType) ?>, GRACE_MOS = <?= json_encode($instGraceMonths) ?>;
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 () {
var down = parseFloat(document.getElementById('inst_down_input').value) || MIN_DOWN; var down = parseFloat(document.getElementById('inst_down_input').value) || MIN_DOWN;
var months = parseInt(document.getElementById('inst_months_input').value,10) || <?= $instMaxMos ?>; var months = parseInt(document.getElementById('inst_months_input').value,10) || <?= $instMaxMos ?>;
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 effectiveRate = RATE; var effectiveRate = RATE;
if (GRACE_TYPE === 'full_free_under_n' && months <= GRACE_MOS) effectiveRate = 0;
// 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 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;
document.getElementById('inst_down_disp').textContent = fmt(down);
document.getElementById('inst_down_disp').textContent = fmt(down); document.getElementById('inst_remaining_disp').textContent = fmt(remaining);
document.getElementById('inst_remaining_disp').textContent = fmt(remaining); document.getElementById('inst_interest_disp').textContent = fmt(totalInterest);
document.getElementById('inst_interest_disp').textContent = fmt(totalInterest); document.getElementById('inst_monthly_disp').textContent = fmt(monthly);
document.getElementById('inst_totalwi_disp').textContent = fmt(totalWI); document.getElementById('inst_interest_label').textContent = 'الفائدة (' + effectiveRate + '% × ' + months + ' شهر)';
document.getElementById('inst_monthly_disp').textContent = fmt(monthly); document.getElementById('inst_monthly_label').textContent = 'القسط الشهري (÷ ' + months + ')';
document.getElementById('inst_interest_label').textContent = 'الفائدة (' + effectiveRate + '% × ' + months + ' شهر)';
document.getElementById('inst_monthly_label').textContent = 'القسط الشهري (÷ ' + months + ')';
}; };
})(); })();
</script> </script>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?>
<div style="margin-top:15px;padding:10px;background:#FEF2F2;border-radius:8px;font-size:12px;color:#DC2626;"> <div style="margin-top:15px;padding:10px;background:#FEF2F2;border-radius:8px;font-size:12px;color:#DC2626;">
&#x26a0;&#xfe0f; مهلة السداد: 15 يوم من تاريخ القبول — بعدها تنتهي الاستمارة &#x26a0;&#xfe0f; مهلة السداد: 15 يوم من تاريخ القبول — بعدها تنتهي الاستمارة
</div> </div>
......
...@@ -189,50 +189,9 @@ class SubscriptionController extends Controller ...@@ -189,50 +189,9 @@ class SubscriptionController extends Controller
if (!$sub) return $this->redirect('/subscriptions')->withError('الاشتراك غير موجود'); if (!$sub) return $this->redirect('/subscriptions')->withError('الاشتراك غير موجود');
if ($sub['status'] === 'paid') return $this->redirect('/subscriptions')->withError('الاشتراك مدفوع بالفعل'); if ($sub['status'] === 'paid') return $this->redirect('/subscriptions')->withError('الاشتراك مدفوع بالفعل');
$olderUnpaid = $db->selectOne( // Subscription payment is ALL-OR-NOTHING per family per year.
"SELECT financial_year FROM subscriptions WHERE member_id = ? AND financial_year < ? AND status IN ('pending','overdue') LIMIT 1", // Redirect to payYear which pays all family rows together.
[(int) $sub['member_id'], $sub['financial_year']] return $this->payYear($request, (string) $sub['member_id'], $sub['financial_year']);
);
if ($olderUnpaid) {
return $this->redirect("/members/{$sub['member_id']}/subscriptions")
->withError('يجب سداد اشتراكات السنة الأقدم أولاً (' . $olderUnpaid['financial_year'] . ')');
}
$amount = bcsub(bcadd($sub['total_amount'], $sub['fine_amount'], 2), $sub['paid_amount'], 2);
$data = $request->all();
$data['member_id'] = (int) $sub['member_id'];
$data['amount'] = $amount;
$data['payment_type'] = 'annual_subscription';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'subscriptions';
$data['related_entity_id'] = (int) $id;
$data['description'] = 'اشتراك سنوي ' . $sub['financial_year'] . ' — ' . ($sub['person_name'] ?? $sub['member_name']);
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withError($result['error']);
}
$employee = App::getInstance()->currentEmployee();
$db->update('subscriptions', [
'paid_amount' => bcadd($sub['paid_amount'], $amount, 2),
'payment_id' => $result['payment_id'],
'receipt_number' => $result['receipt_number'],
'paid_by' => $employee ? (int) $employee->id : null,
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('subscription.paid', [
'subscription_id' => (int) $id,
'member_id' => (int) $sub['member_id'],
'year' => $sub['financial_year'],
'amount' => $amount,
]);
return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withSuccess('تم تسجيل الاشتراك — إيصال: ' . $result['receipt_number']);
} }
public function exempt(Request $request, string $id): Response public function exempt(Request $request, string $id): Response
......
...@@ -58,9 +58,9 @@ final class SubscriptionGenerator ...@@ -58,9 +58,9 @@ final class SubscriptionGenerator
$memberParams[] = $branchId; $memberParams[] = $branchId;
} }
$members = $db->select("SELECT m.id, m.full_name_ar, m.membership_number FROM members m WHERE {$memberWhere}", $memberParams); $members = $db->select("SELECT m.id, m.full_name_ar, m.membership_number, m.activated_at, m.transferred_from_waiver_id FROM members m WHERE {$memberWhere}", $memberParams);
// FY start date: dependents added on/after this date already paid their subscription via addition fee // FY start date: members/dependents activated on/after this date are first-year — skip them
$fyStartYear = (int) ($yearParts[0] ?? date('Y')); $fyStartYear = (int) ($yearParts[0] ?? date('Y'));
$fyStartDate = $fyStartYear . '-07-01'; $fyStartDate = $fyStartYear . '-07-01';
...@@ -70,6 +70,12 @@ final class SubscriptionGenerator ...@@ -70,6 +70,12 @@ final class SubscriptionGenerator
foreach ($members as $member) { foreach ($members as $member) {
$memberId = (int) $member['id']; $memberId = (int) $member['id'];
// Skip first-year members: their membership fee covers the current FY
if (!empty($member['activated_at']) && $member['activated_at'] >= $fyStartDate && empty($member['transferred_from_waiver_id'])) {
$skipped++;
continue;
}
// Check if already generated // Check if already generated
$existing = $db->selectOne( $existing = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'member'", "SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'member'",
...@@ -112,7 +118,8 @@ final class SubscriptionGenerator ...@@ -112,7 +118,8 @@ final class SubscriptionGenerator
if ($existingSp) { $skipped++; continue; } if ($existingSp) { $skipped++; continue; }
$spDiscount = $discountPct ? bcdiv(bcmul($spouseRate, $discountPct, 4), '100', 2) : '0.00'; $spDiscount = $discountPct ? bcdiv(bcmul($spouseRate, $discountPct, 4), '100', 2) : '0.00';
$spTotal = bcsub($spouseRate, $spDiscount, 2); $spTotal = bcsub($spouseRate, $spDiscount, 2);
$spStatus = ($sp['created_at'] >= $fyStartDate) ? 'paid' : 'pending'; if ($sp['created_at'] >= $fyStartDate) { $skipped++; continue; }
$spStatus = 'pending';
try { try {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
'member_id' => $memberId, 'member_id' => $memberId,
...@@ -147,7 +154,8 @@ final class SubscriptionGenerator ...@@ -147,7 +154,8 @@ final class SubscriptionGenerator
if ($existingCh) { $skipped++; continue; } if ($existingCh) { $skipped++; continue; }
$chDiscount = $discountPct ? bcdiv(bcmul($childRate, $discountPct, 4), '100', 2) : '0.00'; $chDiscount = $discountPct ? bcdiv(bcmul($childRate, $discountPct, 4), '100', 2) : '0.00';
$chTotal = bcsub($childRate, $chDiscount, 2); $chTotal = bcsub($childRate, $chDiscount, 2);
$chStatus = ($ch['created_at'] >= $fyStartDate) ? 'paid' : 'pending'; if ($ch['created_at'] >= $fyStartDate) { $skipped++; continue; }
$chStatus = 'pending';
try { try {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
'member_id' => $memberId, 'member_id' => $memberId,
...@@ -182,7 +190,8 @@ final class SubscriptionGenerator ...@@ -182,7 +190,8 @@ final class SubscriptionGenerator
if ($existingTmp) { $skipped++; continue; } if ($existingTmp) { $skipped++; continue; }
$tmpDiscount = $discountPct ? bcdiv(bcmul($tempRate, $discountPct, 4), '100', 2) : '0.00'; $tmpDiscount = $discountPct ? bcdiv(bcmul($tempRate, $discountPct, 4), '100', 2) : '0.00';
$tmpTotal = bcsub($tempRate, $tmpDiscount, 2); $tmpTotal = bcsub($tempRate, $tmpDiscount, 2);
$tmpStatus = ($t['created_at'] >= $fyStartDate) ? 'paid' : 'pending'; if ($t['created_at'] >= $fyStartDate) { $skipped++; continue; }
$tmpStatus = 'pending';
try { try {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
'member_id' => $memberId, 'member_id' => $memberId,
......
...@@ -25,14 +25,16 @@ final class SubscriptionSyncService ...@@ -25,14 +25,16 @@ final class SubscriptionSyncService
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne( $member = $db->selectOne(
"SELECT id, full_name_ar, membership_type, status, transferred_from_waiver_id FROM members WHERE id = ? AND is_archived = 0", "SELECT id, full_name_ar, membership_type, status, transferred_from_waiver_id,
transferred_from_death_id, transferred_from_divorce_id, transferred_from_transfer_id,
activated_at
FROM members WHERE id = ? AND is_archived = 0",
[$memberId] [$memberId]
); );
if (!$member || $member['status'] !== 'active') { if (!$member || $member['status'] !== 'active') {
return; return;
} }
// Waiver-acquired members have no subscriptions — waiver fee covers the annual
if (!empty($member['transferred_from_waiver_id'])) { if (!empty($member['transferred_from_waiver_id'])) {
return; return;
} }
...@@ -43,6 +45,20 @@ final class SubscriptionSyncService ...@@ -43,6 +45,20 @@ final class SubscriptionSyncService
} }
$fy = financial_year(); $fy = financial_year();
// First-year members: membership fee covers the current FY subscription.
// Only skip if NOT a transfer (transfers get markCurrentYearPaid separately).
$isTransfer = !empty($member['transferred_from_death_id'])
|| !empty($member['transferred_from_divorce_id'])
|| !empty($member['transferred_from_transfer_id']);
if (!$isTransfer && !empty($member['activated_at'])) {
$fyStartYear = (int) (explode('/', $fy)[0] ?? date('Y'));
$fyStartDate = $fyStartYear . '-07-01';
if ($member['activated_at'] >= $fyStartDate) {
Logger::info("SubscriptionSyncService: skipping first-year member #{$memberId} (activated {$member['activated_at']} in FY {$fy})");
return;
}
}
$yearSuffix = explode('/', $fy)[0] ?? ''; $yearSuffix = explode('/', $fy)[0] ?? '';
$today = date('Y-m-d'); $today = date('Y-m-d');
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
...@@ -200,11 +216,15 @@ final class SubscriptionSyncService ...@@ -200,11 +216,15 @@ final class SubscriptionSyncService
$empId = $employee ? (int) $employee->id : null; $empId = $employee ? (int) $employee->id : null;
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
// Dependents added on/after FY start already paid subscription via addition fee // Dependents added on/after FY start: their addition fee covers the current year.
// Don't create subscription rows — they'll get rows next July.
$fyStartYear = (int) (explode('/', $fy)[0] ?? date('Y')); $fyStartYear = (int) (explode('/', $fy)[0] ?? date('Y'));
$fyStartDate = $fyStartYear . '-07-01'; $fyStartDate = $fyStartYear . '-07-01';
$depCreatedAt = $db->selectOne("SELECT created_at FROM {$table} WHERE id = ?", [$personId]); $depCreatedAt = $db->selectOne("SELECT created_at FROM {$table} WHERE id = ?", [$personId]);
$status = ($depCreatedAt && $depCreatedAt['created_at'] >= $fyStartDate) ? 'paid' : 'pending'; if ($depCreatedAt && $depCreatedAt['created_at'] >= $fyStartDate) {
Logger::info("SubscriptionSyncService: skipping first-year {$personType} #{$personId} (added in current FY {$fy})");
return;
}
try { try {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
...@@ -217,12 +237,12 @@ final class SubscriptionSyncService ...@@ -217,12 +237,12 @@ final class SubscriptionSyncService
'development_fee' => '0.00', 'development_fee' => '0.00',
'discount_amount' => $discount, 'discount_amount' => $discount,
'total_amount' => $total, 'total_amount' => $total,
'status' => $status, 'status' => 'pending',
'created_at' => $ts, 'created_at' => $ts,
'updated_at' => $ts, 'updated_at' => $ts,
'created_by' => $empId, 'created_by' => $empId,
]); ]);
Logger::info("SubscriptionSyncService: added {$personType} #{$personId} to FY {$fy} for member #{$memberId} (status={$status})"); Logger::info("SubscriptionSyncService: added {$personType} #{$personId} to FY {$fy} for member #{$memberId}");
} catch (\Throwable $insertErr) { } catch (\Throwable $insertErr) {
if (str_contains($insertErr->getMessage(), 'Duplicate entry')) { if (str_contains($insertErr->getMessage(), 'Duplicate entry')) {
Logger::info("SubscriptionSyncService: {$personType} #{$personId} FY {$fy} already exists (race guard)"); Logger::info("SubscriptionSyncService: {$personType} #{$personId} FY {$fy} already exists (race guard)");
......
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `board_offer_tiers` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`board_offer_id` BIGINT UNSIGNED NOT NULL,
`tier_order` TINYINT UNSIGNED NOT NULL DEFAULT 1,
`tier_name_ar` VARCHAR(100) NOT NULL,
`payment_type` ENUM('cash', 'installment') NOT NULL,
`cash_discount_pct` DECIMAL(5,2) NULL,
`down_payment_pct` DECIMAL(5,2) NULL,
`months` INT UNSIGNED NULL,
`interest_rate` DECIMAL(5,2) NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_offer_tiers_offer` (`board_offer_id`),
UNIQUE KEY `uq_offer_tier_order` (`board_offer_id`, `tier_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
",
'down' => "DROP TABLE IF EXISTS `board_offer_tiers`;",
];
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$offers = $db->select("SELECT id FROM board_offers WHERE is_active = 1 ORDER BY id");
if (empty($offers)) {
return;
}
foreach ($offers as $offer) {
$offerId = (int) $offer['id'];
$existing = $db->selectOne(
"SELECT id FROM board_offer_tiers WHERE board_offer_id = ? LIMIT 1",
[$offerId]
);
if ($existing) {
continue;
}
$tiers = [
[
'tier_order' => 1,
'tier_name_ar' => 'كاش — خصم 10%',
'payment_type' => 'cash',
'cash_discount_pct' => '10.00',
'down_payment_pct' => null,
'months' => null,
'interest_rate' => null,
],
[
'tier_order' => 2,
'tier_name_ar' => 'تقسيط 24 شهر — بدون فوائد',
'payment_type' => 'installment',
'cash_discount_pct' => null,
'down_payment_pct' => '50.00',
'months' => 24,
'interest_rate' => '0.00',
],
[
'tier_order' => 3,
'tier_name_ar' => 'تقسيط 40 شهر — فائدة 15%',
'payment_type' => 'installment',
'cash_discount_pct' => null,
'down_payment_pct' => '33.33',
'months' => 40,
'interest_rate' => '15.00',
],
[
'tier_order' => 4,
'tier_name_ar' => 'تقسيط 60 شهر — فائدة 15%',
'payment_type' => 'installment',
'cash_discount_pct' => null,
'down_payment_pct' => '13.33',
'months' => 60,
'interest_rate' => '15.00',
],
];
foreach ($tiers as $tier) {
$db->insert('board_offer_tiers', array_merge($tier, [
'board_offer_id' => $offerId,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]));
}
}
};
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