Commit 67196669 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(members,subscriptions): drop due-date column from family tables, collect...

fix(members,subscriptions): drop due-date column from family tables, collect annual subscription via الخزنة

- Remove the "تاريخ الاستحقاق" column from the spouses/children/temporary
  members tables on the member show page (display-only, no schema change).
- Annual subscription payments no longer post directly from the
  اشتراك سنوي page. SubscriptionController::payYear() now queues a
  payment_request instead of calling PaymentService::processPayment()
  directly; the subscription rows are only marked paid once خزنة العضويات
  (Membership Treasury / Cashier) actually collects it, via a new
  payment_request.completed listener
  (SubscriptionSyncService::completeFamilyYearPayment()).
- Closed the same bypass on the legacy generic /payments/process/{id}
  page, which had its own divergent partial-payment, oldest-year-first
  logic for annual_subscription that skipped the treasury entirely and
  violated the all-or-nothing-per-family rule; that path now redirects to
  the member's subscriptions page instead.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 570cb709
......@@ -1172,7 +1172,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Spouses -->
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f48d; الزوجات (<?= count($spouses) ?>)</div>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>الرقم القومي</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>الرقم القومي</th><th>تاريخ الالتحاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($spouses as $sIdx => $s): ?>
<?php
$sFee = $s['addition_fee'] ?? '0.00';
......@@ -1214,7 +1214,6 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
?><span style="background:#E0F2FE;color:#0369A1;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;"><?= $sTypeLabel ?></span></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($s['national_id'] ?? '—') ?></td>
<td style="font-size:12px;"><?= $s['join_date'] ? e($s['join_date']) : '<span style="color:#D97706;">لم يُحدد بعد</span>' ?></td>
<td style="font-size:12px;"><?= !empty($s['due_date']) ? e(substr($s['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($s['payment_date']) ? e($s['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php
$fee = $s['addition_fee'] ?? '0.00';
......@@ -1246,7 +1245,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr>
<?php if ($sBreakdown): ?>
<tr id="bill-detail-spouse-<?= $sIdx ?>" style="display:none;">
<td colspan="10" style="padding:5px 10px 10px;">
<td colspan="9" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($sBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?>
......@@ -1307,7 +1306,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</div>
</div>
<?php endif; ?>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($children as $cIdx => $c): ?>
<?php
$cFee = $c['addition_fee'] ?? '0.00';
......@@ -1361,7 +1360,6 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
echo (int) $cAge;
?></td>
<td style="font-size:12px;"><?= !empty($c['join_date']) ? e($c['join_date']) : '<span style="color:#D97706;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($c['due_date']) ? e(substr($c['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($c['payment_date']) ? e($c['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php
if ($cIsInactive) {
......@@ -1402,7 +1400,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr>
<?php if ($cBreakdown): ?>
<tr id="bill-detail-child-<?= $cIdx ?>" style="display:none;">
<td colspan="11" style="padding:5px 10px 10px;">
<td colspan="10" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($cBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?>
......@@ -1426,7 +1424,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Temporary Members -->
<div style="padding:15px 20px;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f464; الأعضاء المؤقتون (<?= count($temporaries) ?>)</div>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>الاسم</th><th>نوع العضوية</th><th>الصلة</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>الاسم</th><th>نوع العضوية</th><th>الصلة</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($temporaries as $tIdx => $t): ?>
<?php
$tFee = $t['addition_fee'] ?? '0.00';
......@@ -1466,7 +1464,6 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<td><?= $t['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td>
<td><?= (int) ($t['age_years'] ?? 0) ?></td>
<td style="font-size:12px;"><?= !empty($t['join_date']) ? e($t['join_date']) : '<span style="color:#D97706;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($t['due_date']) ? e(substr($t['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($t['payment_date']) ? e($t['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php
$fee = $t['addition_fee'] ?? '0.00';
......@@ -1489,7 +1486,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr>
<?php if ($tBreakdown): ?>
<tr id="bill-detail-temp-<?= $tIdx ?>" style="display:none;">
<td colspan="11" style="padding:5px 10px 10px;">
<td colspan="10" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($tBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?>
......
......@@ -100,16 +100,8 @@ class PaymentController extends Controller
];
}
// Annual subscription
if (bccomp($summary['unpaid_subscriptions'], '0', 2) > 0) {
$paymentOptions[] = [
'type' => 'annual_subscription',
'label' => 'اشتراك سنوي',
'amount' => $summary['unpaid_subscriptions'],
'description' => 'اشتراكات سنوية غير مدفوعة (' . $summary['unpaid_subs_count'] . ' اشتراك)',
'required' => false,
];
}
// Annual subscription is NOT collected here — it must go through خزنة العضويات
// (Members > Subscriptions > send to treasury), so it's excluded from this page's options.
// Fines
if (bccomp($summary['unpaid_fines'], '0', 2) > 0) {
......@@ -159,6 +151,11 @@ class PaymentController extends Controller
return $this->redirect("/payments/process/{$memberId}")->withError('بيانات الدفع غير صالحة');
}
if ($paymentType === 'annual_subscription') {
return $this->redirect("/members/{$memberId}/subscriptions")
->withError('الاشتراك السنوي يُسدَّد من خزنة العضويات — استخدم زر الإرسال في صفحة اشتراكات العضو');
}
$description = PaymentService::getPaymentTypeLabel($paymentType);
if ($member['form_number']) {
$description .= ' — استمارة ' . $member['form_number'];
......@@ -192,11 +189,6 @@ class PaymentController extends Controller
$this->handleMembershipPayment((int) $memberId, $paymentType, $amount);
}
// Annual subscription paid → update subscriptions table
if ($paymentType === 'annual_subscription') {
$this->handleSubscriptionPayment((int) $memberId, $amount, $result['payment_id']);
}
// Fine paid → update fines table
if ($paymentType === 'fine') {
$this->handleFinePayment((int) $memberId, $amount, $result['payment_id']);
......@@ -309,53 +301,6 @@ class PaymentController extends Controller
}
}
/**
* Mark pending subscriptions as paid (oldest first) up to the paid amount.
*/
private function handleSubscriptionPayment(int $memberId, string $amount, int $paymentId): void
{
$db = App::getInstance()->db();
$subs = $db->select(
"SELECT id, total_amount, fine_amount, paid_amount, financial_year, person_name
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')
ORDER BY financial_year ASC, id ASC",
[$memberId]
);
$remaining = $amount;
$ts = date('Y-m-d H:i:s');
foreach ($subs as $sub) {
if (bccomp($remaining, '0.01', 2) < 0) break;
$due = bcsub(bcadd($sub['total_amount'], $sub['fine_amount'], 2), $sub['paid_amount'], 2);
if (bccomp($due, '0', 2) <= 0) continue;
$pay = bccomp($remaining, $due, 2) >= 0 ? $due : $remaining;
$newPaid = bcadd($sub['paid_amount'], $pay, 2);
$totalDue = bcadd($sub['total_amount'], $sub['fine_amount'], 2);
$status = bccomp($newPaid, $totalDue, 2) >= 0 ? 'paid' : 'partial';
$db->update('subscriptions', [
'paid_amount' => $newPaid,
'payment_id' => $paymentId,
'status' => $status,
'paid_at' => $status === 'paid' ? $ts : null,
'updated_at' => $ts,
], '`id` = ?', [(int) $sub['id']]);
$remaining = bcsub($remaining, $pay, 2);
if ($status === 'paid') {
EventBus::dispatch('subscription.paid', [
'subscription_id' => (int) $sub['id'],
'member_id' => $memberId,
'year' => $sub['financial_year'],
'amount' => $pay,
]);
}
}
}
/**
* Mark pending fines as paid (oldest first) up to the paid amount.
*/
......
......@@ -7,12 +7,11 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Subscriptions\Models\Subscription;
use App\Modules\Subscriptions\Services\SubscriptionGenerator;
use App\Modules\Subscriptions\Services\SubscriptionCalculator;
use App\Modules\Subscriptions\Services\OverdueFineApplicator;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
class SubscriptionController extends Controller
{
......@@ -138,48 +137,24 @@ class SubscriptionController extends Controller
}
$totalAmount = bcadd($subscriptionTotal, $devFee, 2);
$data = $request->all();
$data['member_id'] = (int) $memberId;
$data['amount'] = $totalAmount;
$data['payment_type'] = 'annual_subscription';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'subscriptions';
$data['related_entity_id'] = (int) $rows[0]['id'];
$data['description'] = 'اشتراك سنوي ' . $financialYear . ' — ' . $member['full_name_ar'] . ' (عائلة كاملة' . (bccomp($devFee, '0', 2) > 0 ? ' + مصاريف تنمية' : '') . ')';
// Subscription payments are collected at خزنة العضويات (Membership Treasury), not here —
// this only queues a payment request; SubscriptionSyncService::completeFamilyYearPayment()
// marks the rows paid once the cashier actually collects it (payment_request.completed).
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $totalAmount,
'payment_type' => 'annual_subscription',
'related_entity_type' => 'subscriptions',
'related_entity_id' => (int) $rows[0]['id'],
'description_ar' => 'اشتراك سنوي ' . $financialYear . ' — ' . $member['full_name_ar'] . ' (عائلة كاملة' . (bccomp($devFee, '0', 2) > 0 ? ' + مصاريف تنمية' : '') . ')',
]);
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect("/members/{$memberId}/subscriptions")->withError($result['error']);
}
$ts = date('Y-m-d H:i:s');
$employee = App::getInstance()->currentEmployee();
$paidBy = $employee ? (int) $employee->id : null;
foreach ($rows as $r) {
$remaining = bcsub(bcadd($r['total_amount'], $r['fine_amount'], 2), $r['paid_amount'], 2);
// Dev fee is added to the member row's paid_amount (single flat charge per family)
$rowDevFee = (bccomp($r['development_fee'] ?? '0', '0', 2) > 0) ? $r['development_fee'] : '0.00';
$rowPaid = bcadd(bcadd($r['paid_amount'], $remaining, 2), $rowDevFee, 2);
$db->update('subscriptions', [
'paid_amount' => $rowPaid,
'payment_id' => $result['payment_id'],
'receipt_number' => $result['receipt_number'],
'paid_by' => $paidBy,
'status' => 'paid',
'paid_at' => $ts,
'updated_at' => $ts,
], '`id` = ?', [(int) $r['id']]);
}
EventBus::dispatch('subscription.paid', [
'member_id' => (int) $memberId,
'year' => $financialYear,
'amount' => $totalAmount,
'count' => count($rows),
]);
return $this->redirect("/members/{$memberId}/subscriptions")
->withSuccess('تم سداد اشتراك ' . $financialYear . ' بالكامل (' . count($rows) . ' فرد) — إيصال: ' . $result['receipt_number']);
->withSuccess('تم إرسال طلب سداد اشتراك ' . $financialYear . ' (' . count($rows) . ' فرد) إلى خزنة العضويات — رقم الطلب: ' . $result['request_number']);
}
public function pay(Request $request, string $id): Response
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Subscriptions\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Rules\Services\RuleEngine;
......@@ -367,6 +368,64 @@ final class SubscriptionSyncService
}
}
/**
* Finalize an "annual_subscription" family payment once خزنة العضويات actually collects it.
* Called from the payment_request.completed listener — $subscriptionRowId is the anchor row
* (related_entity_id) used to resolve which member/financial_year the whole family batch covers.
* Subscription payment is ALL-OR-NOTHING per family per year, mirroring the pre-treasury logic
* that used to run synchronously in SubscriptionController::payYear().
*/
public static function completeFamilyYearPayment(int $subscriptionRowId, int $paymentId, ?string $receiptNumber, ?int $paidBy): void
{
try {
$db = App::getInstance()->db();
$anchor = $db->selectOne("SELECT member_id, financial_year FROM subscriptions WHERE id = ?", [$subscriptionRowId]);
if (!$anchor) return;
$memberId = (int) $anchor['member_id'];
$financialYear = $anchor['financial_year'];
$rows = $db->select(
"SELECT id, total_amount, fine_amount, paid_amount, development_fee
FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
[$memberId, $financialYear]
);
if (empty($rows)) return;
$ts = date('Y-m-d H:i:s');
$totalAmount = '0.00';
foreach ($rows as $r) {
$remaining = bcsub(bcadd($r['total_amount'], $r['fine_amount'], 2), $r['paid_amount'], 2);
$rowDevFee = (bccomp($r['development_fee'] ?? '0', '0', 2) > 0) ? $r['development_fee'] : '0.00';
$rowPaid = bcadd(bcadd($r['paid_amount'], $remaining, 2), $rowDevFee, 2);
$totalAmount = bcadd($totalAmount, bcadd($remaining, $rowDevFee, 2), 2);
$db->update('subscriptions', [
'paid_amount' => $rowPaid,
'payment_id' => $paymentId,
'receipt_number' => $receiptNumber,
'paid_by' => $paidBy,
'status' => 'paid',
'paid_at' => $ts,
'updated_at' => $ts,
], '`id` = ?', [(int) $r['id']]);
}
EventBus::dispatch('subscription.paid', [
'member_id' => $memberId,
'year' => $financialYear,
'amount' => $totalAmount,
'count' => count($rows),
]);
} catch (\Throwable $e) {
Logger::error("SubscriptionSyncService::completeFamilyYearPayment failed: " . $e->getMessage(), [
'subscription_row_id' => $subscriptionRowId,
'payment_id' => $paymentId,
]);
}
}
private static function getExemptTypes(): array
{
try {
......
......@@ -29,7 +29,7 @@
<td><span style="color:<?= match($r['status']) { 'paid' => '#059669', 'pending' => '#D97706', 'overdue' => '#DC2626', 'exempt' => '#0284C7', default => '#6B7280' } ?>;font-weight:600;"><?= match($r['status']) { 'paid' => 'مدفوع', 'pending' => 'معلق', 'overdue' => 'متأخر', 'exempt' => 'معفى', default => $r['status'] } ?></span></td>
<td>
<?php if (in_array($r['status'], ['pending', 'overdue']) && can('subscription.collect')): ?>
<a href="/payments/process/<?= (int) $r['member_id'] ?>" class="btn btn-sm btn-primary">دفع</a>
<a href="/members/<?= (int) $r['member_id'] ?>/subscriptions" class="btn btn-sm btn-primary">إرسال لخزنة العضويات</a>
<?php endif; ?>
</td>
</tr>
......
......@@ -216,11 +216,11 @@ foreach ($lateFine['details'] ?? [] as $d) {
<?php if ($isPayable): ?>
<form method="POST" action="/members/<?= (int) $member['id'] ?>/subscriptions/<?= str_replace('/', '-', $fy) ?>/pay">
<?= csrf_field() ?>
<input type="hidden" name="payment_method" value="cash">
<button type="submit" class="btn btn-primary" style="font-size:15px;padding:10px 28px;" onclick="return confirm('تسجيل سداد اشتراك <?= e($fy) ?> بالكامل\n\nالمبلغ: <?= money($invoiceRemaining) ?>\nعدد الأفراد: <?= (int) $yt['count'] ?>\n\nمتأكد؟')">
💰 سداد اشتراك <?= e($fy) ?> بالكامل
<button type="submit" class="btn btn-primary" style="font-size:15px;padding:10px 28px;" onclick="return confirm('إرسال طلب سداد اشتراك <?= e($fy) ?> بالكامل إلى خزنة العضويات\n\nالمبلغ: <?= money($invoiceRemaining) ?>\nعدد الأفراد: <?= (int) $yt['count'] ?>\n\nمتأكد؟')">
📤 إرسال اشتراك <?= e($fy) ?> إلى خزنة العضويات
</button>
</form>
<div style="font-size:11px;color:#94A3B8;margin-top:4px;">يُحصَّل المبلغ فعلياً من خزنة العضويات</div>
<?php else: ?>
<button class="btn btn-outline" disabled style="opacity:0.5;cursor:not-allowed;font-size:14px;padding:10px 24px;" title="يجب سداد اشتراكات <?= e($oldestUnpaidYear ?? '') ?> أولاً">
🔒 يجب سداد <?= e($oldestUnpaidYear ?? '') ?> أولاً
......
......@@ -110,4 +110,26 @@ EventBus::listen('death.completed', function (array $data) {
if ($newMemberId) {
SubscriptionSyncService::refreshForMember($newMemberId);
}
});
// ─── Annual subscription payment collected at خزنة العضويات ─────────────────
// Subscription payments are queued as a payment_request (see SubscriptionController::payYear)
// and only actually collected via the Cashier/Treasury flow. Mark the family's rows paid here,
// once collection is confirmed — not when the request is merely created.
EventBus::listen('payment_request.completed', function (array $data) {
if (($data['payment_type'] ?? '') !== 'annual_subscription') return;
if (($data['related_entity_type'] ?? '') !== 'subscriptions') return;
$subscriptionRowId = (int) ($data['related_entity_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($subscriptionRowId <= 0 || $paymentId <= 0) return;
$employee = \App\Core\App::getInstance()->currentEmployee();
SubscriptionSyncService::completeFamilyYearPayment(
$subscriptionRowId,
$paymentId,
$data['receipt_number'] ?? null,
$employee ? (int) $employee->id : null
);
});
\ No newline at end of file
......@@ -146,11 +146,34 @@ Guards:
4. expireReinstatements(): after 12 months, 'dropped' → 'permanently_dropped'
```
### 4. Payment Collection
### 4. Payment Collection (NEW: routed through خزنة العضويات / Membership Treasury)
```
1. PaymentService processes 'annual_subscription' payment
2. Subscription row updated: status='paid', paid_amount, paid_at, payment_id
3. Multiple rows can be paid in one batch (whole family for one year)
1. SubscriptionController::payYear() no longer calls PaymentService::processPayment() directly.
It only queues a payment_request via PaymentRequestService::createRequest()
(payment_type='annual_subscription', related_entity_type='subscriptions',
related_entity_id=<first pending subscription row id for the family+FY>) and redirects
back with "sent to treasury" — no money is recorded yet, no row is marked paid.
2. The request appears in the Cashier queue (/cashier, "خزنة العضويات") like any other
payment request. A cashier opens a treasury session and collects it via
TreasuryService::collectPayment() (or PaymentRequestService::processRequest() if no
treasury is configured), which calls PaymentService::processPayment() and then dispatches
payment_request.completed.
3. Subscriptions/bootstrap.php listens for payment_request.completed (filtered to
payment_type='annual_subscription' + related_entity_type='subscriptions') and calls
SubscriptionSyncService::completeFamilyYearPayment($subscriptionRowId, $paymentId,
$receiptNumber, $paidBy), which re-resolves the member+FY from the anchor row, re-queries
ALL pending/overdue rows for that member+FY, and marks them paid (status='paid',
paid_amount, paid_at, payment_id, receipt_number, paid_by) — then dispatches
subscription.paid. This mirrors the old synchronous logic, just deferred until actual
collection.
4. Multiple rows are still paid in one batch (whole family for one year) — all-or-nothing.
5. The legacy generic `/payments/process/{memberId}` page (Payments module) no longer offers
or accepts payment_type='annual_subscription' — it used to bypass the treasury entirely
with its own partial-payment, oldest-year-first logic (handleSubscriptionPayment(), now
removed), which also violated the all-or-nothing-per-family rule. Submitting
annual_subscription through that endpoint now redirects to the member's subscriptions page
with an error. The Subscriptions index page's "دفع" action links to
/members/{id}/subscriptions instead of /payments/process/{id}.
```
---
......@@ -192,6 +215,9 @@ Guards:
5. **First-year dependents have NO subscription rows** — addition fee covers current FY; no rows created until next July
6. **Waiver-acquired members skip initial sync** — their subscriptions handled separately
7. **All-or-nothing family payment** — subscription payment always covers ALL family members for the year (no individual row payment)
- **Enforced at خزنة العضويات only (NEW)** — collection happens exclusively via the Cashier/Treasury
flow (see Key Workflow #4). The subscriptions page itself never records money; it only queues a
request. This is now the single entry point — the old generic `/payments/process` bypass is closed.
8. **Validation bypass for first-year** — MembershipValidationService, canPrintCarnet, checkSubscriptionBlock all bypass for members activated in current FY
9. **Grace period** — SUB_GRACE_MONTHS (default 3) before overdue fines start
10. **Progressive fines** — increase per consecutive unpaid year
......@@ -209,3 +235,15 @@ Guards:
4. **syncForMember skips waiver-acquired** — potential gap if waiver logic doesn't create subs
5. **refreshForMember deletes unpaid rows** — if a dependent was incorrectly removed, subscription data is lost
6. **No partial payment** — paid_amount is either 0 or full amount
## Change Log
- 2026-08-30: Subscription payment collection moved to خزنة العضويات (Membership Treasury / Cashier
module) — `SubscriptionController::payYear()` now queues a `payment_request` instead of calling
`PaymentService::processPayment()` directly; `SubscriptionSyncService::completeFamilyYearPayment()`
finalizes the subscription rows from a `payment_request.completed` listener in
`Subscriptions/bootstrap.php`. The legacy `/payments/process/{memberId}` generic payment page
(Payments module) no longer accepts `annual_subscription` — it had its own divergent, partial-payment
oldest-year-first logic that bypassed both the treasury and the all-or-nothing-per-family rule.
Root cause: client feedback that subscription payments should be collected at the treasury, not
recorded directly from the subscription page. Removed `due_date` column from the member show page's
spouse/child/temporary-member tables (client request) — no schema change, display-only.
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