Commit 0a2372df authored by Fares's avatar Fares

feat(subscriptions): add per-row payment audit trail (receipt_number, paid_by, paid_at)

Each subscription row now independently stores:
- receipt_number: denormalized from receipts table for quick audit access
- paid_by: FK to employees — who processed the payment
- paid_at: already existed

Migration adds columns and backfills existing paid rows from payments/receipts.
View shows "بيانات السداد" column with date, receipt number, and employee name.
Model query now JOINs employees for paid_by_name display.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 316c58eb
......@@ -148,17 +148,21 @@ class SubscriptionController extends Controller
}
$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'],
'status' => 'paid',
'paid_at' => $ts,
'updated_at' => $ts,
'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']]);
}
......@@ -205,12 +209,15 @@ class SubscriptionController extends Controller
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'],
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'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', [
......
......@@ -19,19 +19,26 @@ class Subscription extends Model
'member_id', 'financial_year', 'person_type', 'person_id', 'person_name',
'base_amount', 'development_fee', 'discount_amount', 'total_amount',
'paid_amount', 'fine_amount', 'status', 'paid_at', 'payment_id',
'receipt_number', 'paid_by',
'exempted_by', 'exemption_reason',
];
public static function getForMember(int $memberId, ?string $year = null): array
{
$db = App::getInstance()->db();
$where = 'member_id = ?';
$where = 's.member_id = ?';
$params = [$memberId];
if ($year) {
$where .= ' AND financial_year = ?';
$where .= ' AND s.financial_year = ?';
$params[] = $year;
}
return $db->select("SELECT * FROM subscriptions WHERE {$where} ORDER BY financial_year DESC, person_type ASC", $params);
return $db->select(
"SELECT s.*, e.full_name_ar as paid_by_name
FROM subscriptions s
LEFT JOIN employees e ON e.id = s.paid_by
WHERE {$where} ORDER BY s.financial_year DESC, s.person_type ASC",
$params
);
}
public static function getMemberYearTotal(int $memberId, string $year): array
......
......@@ -143,6 +143,7 @@ foreach ($lateFine['details'] ?? [] as $d) {
<th>الصافي</th>
<th>غرامة</th>
<th>الحالة</th>
<th>بيانات السداد</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $s):
......@@ -158,6 +159,19 @@ foreach ($lateFine['details'] ?? [] as $d) {
<td style="font-weight:600;"><?= money($s['total_amount']) ?></td>
<td style="color:#DC2626;"><?= bccomp($s['fine_amount'] ?? '0', '0', 2) > 0 ? money($s['fine_amount']) : '—' ?></td>
<td><span style="color:<?= $rowStatusColor ?>;font-weight:600;"><?= $rowStatusLabel ?></span></td>
<td style="font-size:11px;color:#6B7280;line-height:1.6;">
<?php if ($s['status'] === 'paid' && !empty($s['paid_at'])): ?>
<div><?= arabic_date(substr($s['paid_at'], 0, 10)) ?></div>
<?php if (!empty($s['receipt_number'])): ?>
<div style="direction:ltr;text-align:right;font-weight:600;color:#1E293B;"><?= e($s['receipt_number']) ?></div>
<?php endif; ?>
<?php if (!empty($s['paid_by_name'])): ?>
<div style="color:#0D9488;"><?= e($s['paid_by_name']) ?></div>
<?php endif; ?>
<?php else: ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
......@@ -168,19 +182,19 @@ foreach ($lateFine['details'] ?? [] as $d) {
<td style="font-size:12px;color:#7C3AED;"><?php $totalDisc = array_reduce($rows, fn($c, $r) => bcadd($c, $r['discount_amount'] ?? '0', 2), '0.00'); echo bccomp($totalDisc, '0', 2) > 0 ? '-' . money($totalDisc) : '—'; ?></td>
<td><?= money($yt['total_amount']) ?></td>
<td style="color:#DC2626;"><?= bccomp($yt['total_fine'], '0', 2) > 0 ? money($yt['total_fine']) : '—' ?></td>
<td></td>
<td colspan="2"></td>
</tr>
<?php if (bccomp($yearDevFee, '0', 2) > 0): ?>
<tr style="background:#F0F9FF;font-weight:600;">
<td colspan="4" style="text-align:center;color:#0369A1;">مصاريف تنمية (رسم ثابت)</td>
<td style="color:#0369A1;"><?= money($yearDevFee) ?></td>
<td colspan="2"></td>
<td colspan="3"></td>
</tr>
<?php endif; ?>
<tr style="background:#1E293B;color:#fff;font-weight:700;">
<td colspan="4" style="text-align:center;font-size:14px;">إجمالي الفاتورة</td>
<td style="font-size:14px;"><?= money($invoiceGrand) ?></td>
<td colspan="2" style="font-size:12px;"><?= bccomp($yt['total_paid'], '0', 2) > 0 ? 'المدفوع: ' . money($yt['total_paid']) : '' ?></td>
<td colspan="3" style="font-size:12px;"><?= bccomp($yt['total_paid'], '0', 2) > 0 ? 'المدفوع: ' . money($yt['total_paid']) : '' ?></td>
</tr>
</tfoot>
</table>
......
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$cols = [
'receipt_number' => "ALTER TABLE subscriptions ADD COLUMN receipt_number VARCHAR(50) NULL DEFAULT NULL AFTER payment_id",
'paid_by' => "ALTER TABLE subscriptions ADD COLUMN paid_by BIGINT UNSIGNED NULL DEFAULT NULL AFTER receipt_number",
];
foreach ($cols as $col => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'subscriptions' AND column_name = ?",
[$col]
);
if (!$exists) {
$db->raw($sql);
}
}
// Backfill existing paid subscriptions from payments + receipts tables
$db->raw("
UPDATE subscriptions s
JOIN payments p ON p.id = s.payment_id
LEFT JOIN receipts r ON r.id = p.receipt_id
SET s.receipt_number = r.receipt_number,
s.paid_by = p.received_by_employee_id
WHERE s.status = 'paid' AND s.payment_id IS NOT NULL AND s.receipt_number IS NULL
");
};
# Subscriptions Module — Architecture Map
> **Last updated:** 2026-07-18 (added syncForMember + member.activated listener; production backfill of missing 2026/2027 rows)
> **Last updated:** 2026-07-21 (added receipt_number + paid_by audit columns per subscription row)
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -69,6 +69,8 @@ app/Modules/Subscriptions/
| status | varchar(50) | NO | MUL | pending | pending/paid/overdue/exempt |
| paid_at | timestamp | YES | | | When fully paid |
| payment_id | bigint unsigned | YES | MUL | | FK to payments |
| receipt_number | varchar(50) | YES | | | Receipt number (denormalized from receipts table for audit) |
| paid_by | bigint unsigned | YES | | | FK to employees — who processed the payment |
| exempted_by | bigint unsigned | YES | | | FK to employees |
| exemption_reason | text | YES | | | Required for exemptions |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
......@@ -202,7 +204,7 @@ by ineligible members (archived, deceased, waived, not yet active).
4. Call PaymentService::processPayment() with:
- payment_type = 'annual_subscription'
- related_entity_type = 'subscriptions'
5. Update subscription: status='paid', paid_at, payment_id
5. Update subscription: status='paid', paid_at, payment_id, receipt_number, paid_by
6. Dispatch: subscription.paid event
```
......
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