Commit dd952bfe authored by Fares's avatar Fares

feat(transfers): child separation auto-completes after payment, shows child name in cashier

- Payment request includes child's name + fee breakdown in notes JSON
- Cashier/Treasury queues display child's name instead of parent's
- Skip archive snapshot for child_separation (parent keeps membership)
- Generate form_number for new member via FormNumberGenerator
- Auto-complete transfer immediately after cashier payment (no manual button)
- Hide manual "إتمام التحويل" button for child_separation type
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 21dcc185
...@@ -9,6 +9,12 @@ ...@@ -9,6 +9,12 @@
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php <?php
$typeLabel = \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel($pr['payment_type']); $typeLabel = \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel($pr['payment_type']);
// For separation_fee, display child's name instead of parent
$processDisplayName = $pr['member_name'] ?? '—';
if ($pr['payment_type'] === 'separation_fee' && !empty($pr['notes'])) {
$pNotes = json_decode($pr['notes'], true);
if (!empty($pNotes['child_name'])) $processDisplayName = $pNotes['child_name'];
}
$statusColor = match($pr['status']) { $statusColor = match($pr['status']) {
'pending' => '#F59E0B', 'pending' => '#F59E0B',
'processing' => '#3B82F6', 'processing' => '#3B82F6',
...@@ -100,7 +106,7 @@ $statusLabel = match($pr['status']) { ...@@ -100,7 +106,7 @@ $statusLabel = match($pr['status']) {
<tr> <tr>
<td style="padding:6px 0;color:#6B7280;">الاسم</td> <td style="padding:6px 0;color:#6B7280;">الاسم</td>
<td style="padding:6px 0;font-weight:700;"> <td style="padding:6px 0;font-weight:700;">
<a href="/members/<?= (int)$pr['member_id'] ?>" style="color:#0D7377;"><?= e($pr['member_name'] ?? '—') ?></a> <a href="/members/<?= (int)$pr['member_id'] ?>" style="color:#0D7377;"><?= e($processDisplayName) ?></a>
</td> </td>
</tr> </tr>
<tr> <tr>
......
...@@ -144,6 +144,10 @@ ...@@ -144,6 +144,10 @@
$nd2 = json_decode($r['notes'], true); $nd2 = json_decode($r['notes'], true);
if (!empty($nd2['recipient_name'])) $displayName = $nd2['recipient_name']; if (!empty($nd2['recipient_name'])) $displayName = $nd2['recipient_name'];
} }
if ($r['payment_type'] === 'separation_fee' && !empty($r['notes'])) {
$nd2 = json_decode($r['notes'], true);
if (!empty($nd2['child_name'])) $displayName = $nd2['child_name'];
}
?> ?>
<?php if ((int)($r['member_id'] ?? 0) > 0): ?> <?php if ((int)($r['member_id'] ?? 0) > 0): ?>
<a href="/members/<?= (int)$r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($displayName) ?></a> <a href="/members/<?= (int)$r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($displayName) ?></a>
...@@ -161,6 +165,13 @@ ...@@ -161,6 +165,13 @@
$dcRow = \App\Core\App::getInstance()->db()->selectOne("SELECT form_number FROM death_cases WHERE id = ?", [(int) $r['related_entity_id']]); $dcRow = \App\Core\App::getInstance()->db()->selectOne("SELECT form_number FROM death_cases WHERE id = ?", [(int) $r['related_entity_id']]);
$fnDisplay = $dcRow['form_number'] ?? null; $fnDisplay = $dcRow['form_number'] ?? null;
} }
if (!$fnDisplay && $r['payment_type'] === 'separation_fee' && !empty($r['related_entity_id']) && ($r['related_entity_type'] ?? '') === 'transfer_requests') {
$trRow = \App\Core\App::getInstance()->db()->selectOne(
"SELECT m.form_number FROM transfer_requests tr JOIN members m ON m.id = tr.target_member_id WHERE tr.id = ?",
[(int) $r['related_entity_id']]
);
$fnDisplay = $trRow['form_number'] ?? null;
}
echo e($fnDisplay ?: '—'); echo e($fnDisplay ?: '—');
?></td> ?></td>
<td><?= $typeLabel ?></td> <td><?= $typeLabel ?></td>
......
...@@ -163,6 +163,15 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -163,6 +163,15 @@ EventBus::listen('payment_request.completed', function (array $data) {
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]); ], '`id` = ?', [$entityId]);
EventBus::dispatch($cfg['event'], [$cfg['key'] => $entityId, 'payment_id' => $paymentId]); EventBus::dispatch($cfg['event'], [$cfg['key'] => $entityId, 'payment_id' => $paymentId]);
// Auto-complete separation transfers after payment (no manual button needed)
if ($paymentType === 'separation_fee') {
try {
\App\Modules\Transfers\Services\TransferProcessor::execute($entityId);
} catch (\Throwable $autoErr) {
\App\Core\Logger::error("Auto-complete transfer #{$entityId} failed: " . $autoErr->getMessage());
}
}
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
......
...@@ -337,13 +337,40 @@ class TransferController extends Controller ...@@ -337,13 +337,40 @@ class TransferController extends Controller
// Send payment to cashier queue // Send payment to cashier queue
$amount = $totalWithSurcharge; $amount = $totalWithSurcharge;
if (bccomp((string) $amount, '0', 2) > 0) { if (bccomp((string) $amount, '0', 2) > 0) {
// Build payment request notes with child name + fee breakdown for cashier display
$paymentNotes = null;
if ($transferType === 'child_separation' && $child) {
$childName = $child['full_name_ar'] ?? '';
$feeBreakdown = [];
if (bccomp($feeCalc['separation_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الفصل: ' . money($feeCalc['separation_fee']);
}
if (bccomp($feeCalc['form_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الاستمارة: ' . money($feeCalc['form_fee']);
}
if (bccomp($feeCalc['annual_subscription_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'الاشتراك السنوي: ' . money($feeCalc['annual_subscription_fee']);
}
if (bccomp($companionSurcharge, '0', 2) > 0) {
$feeBreakdown[] = 'فرق مرافقين: ' . money($companionSurcharge);
}
$feeBreakdown[] = '💵 الإجمالي: ' . money($amount);
$paymentNotes = json_encode([
'child_name' => $childName,
'fee_breakdown' => $feeBreakdown,
], JSON_UNESCAPED_UNICODE);
}
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'amount' => $amount, 'amount' => $amount,
'payment_type' => 'separation_fee', 'payment_type' => 'separation_fee',
'related_entity_type' => 'transfer_requests', 'related_entity_type' => 'transfer_requests',
'related_entity_id' => (int) $transferReq->id, 'related_entity_id' => (int) $transferReq->id,
'description_ar' => 'رسوم تحويل/فصل — طلب #' . $transferReq->id, 'description_ar' => $transferType === 'child_separation' && $child
? 'رسوم فصل — ' . ($child['full_name_ar'] ?? '') . ' — طلب #' . $transferReq->id
: 'رسوم تحويل/فصل — طلب #' . $transferReq->id,
'notes' => $paymentNotes,
]); ]);
if ($result['success']) { if ($result['success']) {
......
...@@ -7,6 +7,7 @@ use App\Core\App; ...@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\Archive\Services\ArchiveService; use App\Modules\Archive\Services\ArchiveService;
use App\Modules\Members\Services\FormNumberGenerator;
use App\Modules\Members\Services\MemberNumberGenerator; use App\Modules\Members\Services\MemberNumberGenerator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Transfers\Models\TransferRequest; use App\Modules\Transfers\Models\TransferRequest;
...@@ -43,7 +44,9 @@ final class TransferProcessor ...@@ -43,7 +44,9 @@ final class TransferProcessor
$db->beginTransaction(); $db->beginTransaction();
try { try {
// 1. Take archive snapshot of source member // 1. Take archive snapshot of source member (skip for child_separation — parent keeps their membership)
$snapshotId = null;
if ($request['transfer_type'] !== 'child_separation') {
$snapshotId = ArchiveService::takeSnapshot( $snapshotId = ArchiveService::takeSnapshot(
'members', 'members',
(int) $sourceMember['id'], (int) $sourceMember['id'],
...@@ -52,6 +55,7 @@ final class TransferProcessor ...@@ -52,6 +55,7 @@ final class TransferProcessor
null, null,
$sourceMember['membership_number'] $sourceMember['membership_number']
); );
}
// 2. Determine the subject of transfer // 2. Determine the subject of transfer
$subjectName = $sourceMember['full_name_ar']; $subjectName = $sourceMember['full_name_ar'];
...@@ -143,6 +147,9 @@ final class TransferProcessor ...@@ -143,6 +147,9 @@ final class TransferProcessor
?? $sourceMember['phone_mobile'] ?? $sourceMember['phone_mobile']
?? '00000000000'; ?? '00000000000';
// Generate form_number for new member
$formNumber = FormNumberGenerator::next();
$newMemberData = [ $newMemberData = [
'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName, 'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null, 'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null,
...@@ -162,6 +169,7 @@ final class TransferProcessor ...@@ -162,6 +169,7 @@ final class TransferProcessor
'phone_mobile' => $phoneMobile, 'phone_mobile' => $phoneMobile,
'membership_value' => $request['new_membership_value'], 'membership_value' => $request['new_membership_value'],
'membership_number' => $newNumber, 'membership_number' => $newNumber,
'form_number' => $formNumber ? (string) $formNumber : 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'),
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
......
...@@ -134,7 +134,7 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes']) ...@@ -134,7 +134,7 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (in_array($transfer['status'], ['approved', 'fee_paid'])): ?> <?php if (in_array($transfer['status'], ['approved', 'fee_paid']) && ($transfer['transfer_type'] ?? '') !== 'child_separation'): ?>
<?php if (can('transfer.approve')): ?> <?php if (can('transfer.approve')): ?>
<form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/complete" style="margin-top:15px;"> <form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/complete" style="margin-top:15px;">
<?= csrf_field() ?> <?= csrf_field() ?>
......
...@@ -89,7 +89,18 @@ ...@@ -89,7 +89,18 @@
<tr> <tr>
<td style="font-family:monospace;font-size:12px;"><?= e($r['request_number']) ?></td> <td style="font-family:monospace;font-size:12px;"><?= e($r['request_number']) ?></td>
<td> <td>
<div style="font-weight:600;"><?= e($r['member_name'] ?? $r['description_ar'] ?? '—') ?></div> <?php
$tqName = $r['member_name'] ?? $r['description_ar'] ?? '—';
if ($r['payment_type'] === 'death_fee' && !empty($r['notes'])) {
$tqNd = json_decode($r['notes'], true);
if (!empty($tqNd['recipient_name'])) $tqName = $tqNd['recipient_name'];
}
if ($r['payment_type'] === 'separation_fee' && !empty($r['notes'])) {
$tqNd = json_decode($r['notes'], true);
if (!empty($tqNd['child_name'])) $tqName = $tqNd['child_name'];
}
?>
<div style="font-weight:600;"><?= e($tqName) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= e($r['form_number'] ?? '') ?></div> <div style="font-size:11px;color:#9CA3AF;"><?= e($r['form_number'] ?? '') ?></div>
</td> </td>
<td><?= e($typeLabel) ?></td> <td><?= e($typeLabel) ?></td>
......
# Transfers Module — Architecture Map # Transfers Module — Architecture Map
> **Last updated:** 2026-07-21 (bug fix: date_of_birth / phone_mobile NOT NULL failures in TransferProcessor) > **Last updated:** 2026-07-22 (child_separation: auto-complete after payment, skip archive snapshot, form_number generation, cashier displays child name + fee breakdown)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -113,7 +113,7 @@ requested → approved → fee_paid → completed ...@@ -113,7 +113,7 @@ requested → approved → fee_paid → completed
**Status values:** requested, approved, fee_paid, completed, rejected **Status values:** requested, approved, fee_paid, completed, rejected
**Key invariant:** A transfer can only be completed (`TransferProcessor::execute`) if status is `approved` or `fee_paid`. The source member is archived and a new member record is created with the SAME membership number. **Key invariant:** A transfer can only be completed (`TransferProcessor::execute`) if status is `approved` or `fee_paid`. For non-child_separation types: the source member is archived and a new member record is created with the SAME membership number. For child_separation: parent keeps their membership (no archive), child gets a NEW number + NEW form_number. Auto-completes immediately after cashier payment (no manual "إتمام" button).
--- ---
...@@ -147,16 +147,20 @@ requested → approved → fee_paid → completed ...@@ -147,16 +147,20 @@ requested → approved → fee_paid → completed
- Board approves → status='approved' - Board approves → status='approved'
- Dispatch: transfer.approved - Dispatch: transfer.approved
4. [Cashier processes payment OR POST /transfers/{id}/pay] 4. [Cashier processes payment]
- PaymentService processes payment (type='separation_fee') - PaymentRequestService processes payment (type='separation_fee')
- Status → 'fee_paid' - Cashier bootstrap listener: Status → 'fee_paid'
- Dispatch: transfer.fee_paid - Dispatch: transfer.fee_paid
- **AUTO-COMPLETE** (child_separation only): immediately calls TransferProcessor::execute()
No manual "إتمام التحويل" button needed.
5. POST /transfers/{id}/complete 5. TransferProcessor::execute() (auto-triggered for child_separation, manual for others):
- TransferProcessor::execute() runs in transaction: - TransferProcessor::execute() runs in transaction:
a. Take archive snapshot of source member a. Take archive snapshot of source member **(SKIPPED for child_separation)**
b. Archive source member (membership_number=NULL, status='transferred', is_archived=1) b. child_separation: parent KEEPS membership, child gets NEW membership number
c. Create new member with SAME membership_number other types: Archive source member (membership_number=NULL, status='transferred', is_archived=1)
c. Generate form_number via FormNumberGenerator::next()
d. Create new member with form_number + membership_number
d. Mark child as 'separated' d. Mark child as 'separated'
e. Create dependents from notes.dependents_data (spouses, children, temps) e. Create dependents from notes.dependents_data (spouses, children, temps)
f. Record number chain via ArchiveService f. Record number chain via ArchiveService
...@@ -214,7 +218,7 @@ Companion Surcharge: if new owner brings MORE dependents than source had: ...@@ -214,7 +218,7 @@ Companion Surcharge: if new owner brings MORE dependents than source had:
| Event | Listener Module | Action | | Event | Listener Module | Action |
|-------|----------------|--------| |-------|----------------|--------|
| `transfer.completed` | Accounting | `AccountingIntegrationService::onTransferCompleted()` — posts journal entry (AR write-off) | | `transfer.completed` | Accounting | `AccountingIntegrationService::onTransferCompleted()` — posts journal entry (AR write-off) |
| `transfer.fee_paid` | Cashier | Updates `transfer_requests.status` to 'fee_paid' (via payment_request.completed handler) | | `transfer.fee_paid` | Cashier | Updates `transfer_requests.status` to 'fee_paid', then auto-calls TransferProcessor::execute() for separation_fee (via payment_request.completed handler in Cashier/bootstrap.php) |
--- ---
...@@ -224,7 +228,7 @@ Companion Surcharge: if new owner brings MORE dependents than source had: ...@@ -224,7 +228,7 @@ Companion Surcharge: if new owner brings MORE dependents than source had:
| Module | What it uses | | Module | What it uses |
|--------|-------------| |--------|-------------|
| Members | MemberNumberGenerator (generate next number on conflict), NationalIdParser (parse recipient NID) | | Members | MemberNumberGenerator (generate next number on conflict), NationalIdParser (parse recipient NID), FormNumberGenerator (generate form_number for new member on completion) |
| Archive | ArchiveService (takeSnapshot, recordNumberTransfer) | | Archive | ArchiveService (takeSnapshot, recordNumberTransfer) |
| Payments | PaymentService (processPayment for direct pay) | | Payments | PaymentService (processPayment for direct pay) |
| Cashier | PaymentRequestService (queue payment for cashier processing) | | Cashier | PaymentRequestService (queue payment for cashier processing) |
......
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