Commit 1c8ee16d authored by Fares's avatar Fares

feat(transfers): complete child separation with dependents, fix effective date logic

- Fix effective date: use min(max(work_date, graduation_date), date_turned_25) instead of min(work_date, date_turned_25)
- Make graduation_date mandatory when child is employed
- Annual subscription now includes all family members (member + spouses + children + temps)
- Add dependents section to form: user specifies counts and details (name, national_id) for each person joining new membership
- TransferProcessor creates dependent records (spouses, children, temporary_members) from notes JSON on completion
- Migration adds target_spouses_count, target_children_count, target_temps_count columns
- Updated architecture map
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 5a5aba46
......@@ -113,6 +113,9 @@ class TransferController extends Controller
if (!$workDate) {
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب إدخال تاريخ مباشرة العمل عند اختيار موظف');
}
if (!$graduationDate) {
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب إدخال تاريخ التخرج عند اختيار موظف');
}
}
$date25Override = trim($request->post('date_turned_25', '')) ?: null;
......@@ -125,12 +128,23 @@ class TransferController extends Controller
$child['date_of_birth'],
$isEmployed,
$workDate,
$date25Override
$date25Override,
$graduationDate
);
$dateTurned25 = $dates['date_turned_25'];
$effectiveTransferDate = $dates['effective_transfer_date'];
}
// Read dependent counts for new membership (child_separation)
$depSpouses = 0;
$depChildren = 0;
$depTemps = 0;
if ($transferType === 'child_separation') {
$depSpouses = (int) $request->post('dep_spouses_count', 0);
$depChildren = (int) $request->post('dep_children_count', 0);
$depTemps = (int) $request->post('dep_temps_count', 0);
}
// Calculate fees
$qualCode = null;
......@@ -141,7 +155,10 @@ class TransferController extends Controller
(bool) $isEmployed,
$workDate,
$effectiveTransferDate,
$qualCode
$qualCode,
$depSpouses,
$depChildren,
$depTemps
);
} else {
$feeCalc = SeparationFeeCalculator::calculate((int) $memberId, $childId, $qualCode);
......@@ -203,12 +220,21 @@ class TransferController extends Controller
$employee = App::getInstance()->currentEmployee();
// Build notes JSON with recipient data if full_transfer
// Build notes JSON
$notesJson = null;
if ($transferType === 'full_transfer' && $recipientData) {
$notesPayload = ['recipient_data' => $recipientData];
if ($notes) $notesPayload['user_notes'] = $notes;
$notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
} elseif ($transferType === 'child_separation' && ($depSpouses > 0 || $depChildren > 0 || $depTemps > 0)) {
$dependentsData = [
'spouses' => $request->post('dep_spouses', []),
'children' => $request->post('dep_children', []),
'temporary_members' => $request->post('dep_temps', []),
];
$notesPayload = ['dependents_data' => $dependentsData];
if ($notes) $notesPayload['user_notes'] = $notes;
$notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
} else {
$notesJson = $notes ?: null;
}
......@@ -238,6 +264,9 @@ class TransferController extends Controller
'work_date' => $workDate,
'date_turned_25' => $dateTurned25,
'effective_transfer_date' => $effectiveTransferDate,
'target_spouses_count' => $depSpouses > 0 ? $depSpouses : null,
'target_children_count' => $depChildren > 0 ? $depChildren : null,
'target_temps_count' => $depTemps > 0 ? $depTemps : null,
]);
if (FormBridge::exists('TRANSFER_SEPARATION')) {
......@@ -417,20 +446,28 @@ class TransferController extends Controller
$isEmployedPost = $request->post('is_employed', '');
$isEmployed = $isEmployedPost !== '' ? (bool)(int)$isEmployedPost : false;
$workDate = trim($request->post('work_date', '')) ?: null;
$graduationDate = trim($request->post('graduation_date', '')) ?: null;
$date25Override = trim($request->post('date_turned_25', '')) ?: null;
$depSpouses = (int) $request->post('dep_spouses_count', 0);
$depChildren = (int) $request->post('dep_children_count', 0);
$depTemps = (int) $request->post('dep_temps_count', 0);
if ($child && !empty($child['date_of_birth'])) {
$dates = SeparationFeeCalculator::computeChildSeparationDates(
$child['date_of_birth'],
$isEmployed,
$isEmployed ? $workDate : null,
$date25Override
$date25Override,
$isEmployed ? $graduationDate : null
);
$result = SeparationFeeCalculator::calculateForChildSeparation(
$memberId, $childId, $isEmployed,
$isEmployed ? $workDate : null,
$dates['effective_transfer_date'],
$qualCode
$qualCode,
$depSpouses,
$depChildren,
$depTemps
);
$result['date_turned_25'] = $dates['date_turned_25'];
$result['effective_transfer_date'] = $dates['effective_transfer_date'];
......
......@@ -28,6 +28,7 @@ class TransferRequest extends Model
'board_decision_notes', 'approved_by', 'approved_at',
'completed_at', 'status', 'notes',
'is_employed', 'graduation_date', 'work_date', 'date_turned_25', 'effective_transfer_date',
'target_spouses_count', 'target_children_count', 'target_temps_count',
];
public static function getForMember(int $memberId): array
......
......@@ -142,29 +142,34 @@ final class SeparationFeeCalculator
/**
* Calculate the effective transfer date for فصل أبناء:
* - If employed: effective = min(work_date, date_turned_25)
* - If not employed (or work_date is null): effective = date_turned_25
*
* date_turned_25 defaults to DOB + 25 years but may be overridden by user.
* - If employed: effective = min(max(work_date, graduation_date), date_turned_25)
* Both work AND graduation must be completed; the later of the two is when
* both conditions are met. Then compare with date_turned_25 (take earlier).
* - If not employed: effective = date_turned_25
*
* @param string $dobString Child's date_of_birth (YYYY-MM-DD)
* @param bool $isEmployed
* @param string|null $workDate YYYY-MM-DD, required when employed
* @param string|null $date25Override User-supplied override for date_turned_25
* @param string|null $graduationDate YYYY-MM-DD, required when employed
* @return array{date_turned_25: string, effective_transfer_date: string}
*/
public static function computeChildSeparationDates(
string $dobString,
bool $isEmployed,
?string $workDate,
?string $date25Override = null
?string $date25Override = null,
?string $graduationDate = null
): array {
$dob = new \DateTime(substr($dobString, 0, 10));
$dob->modify('+25 years');
$dateTurned25 = $date25Override ?? $dob->format('Y-m-d');
if ($isEmployed && $workDate) {
$effective = min($dateTurned25, $workDate);
if ($isEmployed && $workDate && $graduationDate) {
$conditionMetDate = max($workDate, $graduationDate);
$effective = min($conditionMetDate, $dateTurned25);
} elseif ($isEmployed && $workDate) {
$effective = min($workDate, $dateTurned25);
} else {
$effective = $dateTurned25;
}
......@@ -185,7 +190,10 @@ final class SeparationFeeCalculator
bool $isEmployed,
?string $workDate,
string $effectiveTransferDate,
?string $qualificationCode = null
?string $qualificationCode = null,
int $depSpouses = 0,
int $depChildren = 0,
int $depTemps = 0
): array {
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]);
......@@ -242,10 +250,19 @@ final class SeparationFeeCalculator
$activatedAt = $member['activated_at'] ?? $member['created_at'] ?? date('Y-m-d');
$annualSubCovered = self::isCurrentYearSubscriptionCovered($activatedAt);
$annualSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$memberSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$spouseSub = ServicePrice::getPrice('SVC_ANNUAL_SPOUSE', '492.00');
$childSub = ServicePrice::getPrice('SVC_ANNUAL_CHILD', '222.00');
$tempSub = ServicePrice::getPrice('SVC_ANNUAL_TEMP', '222.00');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
$annualSubscriptionFee = $annualSubCovered ? '0.00' : bcadd($annualSub, $devFee, 2);
$familyTotal = $memberSub;
$familyTotal = bcadd($familyTotal, bcmul($spouseSub, (string) $depSpouses, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($childSub, (string) $depChildren, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($tempSub, (string) $depTemps, 2), 2);
$annualSubscriptionFee = $annualSubCovered ? '0.00' : bcadd($familyTotal, $devFee, 2);
$totalFee = bcadd(bcadd($separationFee, $formFee, 2), $annualSubscriptionFee, 2);
......@@ -266,6 +283,9 @@ final class SeparationFeeCalculator
'effective_transfer_date' => $effectiveTransferDate,
'is_employed' => $isEmployed,
'work_date' => $workDate,
'dep_spouses' => $depSpouses,
'dep_children' => $depChildren,
'dep_temps' => $depTemps,
];
}
......
......@@ -163,6 +163,60 @@ final class TransferProcessor
], '`id` = ?', [(int) $request['spouse_id']]);
}
// Child separation: create dependents specified on the form
if ($request['transfer_type'] === 'child_separation' && !empty($request['notes'])) {
$notesDecoded = json_decode($request['notes'], true) ?: [];
$depsData = $notesDecoded['dependents_data'] ?? [];
if (!empty($depsData['spouses'])) {
foreach ($depsData['spouses'] as $sp) {
if (empty($sp['full_name_ar'])) continue;
$db->insert('spouses', [
'member_id' => $newMemberId,
'full_name_ar' => $sp['full_name_ar'],
'national_id' => $sp['national_id'] ?? null,
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
if (!empty($depsData['children'])) {
foreach ($depsData['children'] as $ch) {
if (empty($ch['full_name_ar'])) continue;
$db->insert('children', [
'member_id' => $newMemberId,
'full_name_ar' => $ch['full_name_ar'],
'national_id' => $ch['national_id'] ?? null,
'gender' => $ch['gender'] ?? 'male',
'classification' => 'active',
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
$tempsArr = $depsData['temps'] ?? $depsData['temporary_members'] ?? [];
if (!empty($tempsArr)) {
foreach ($tempsArr as $tm) {
if (empty($tm['full_name_ar'])) continue;
$db->insert('temporary_members', [
'member_id' => $newMemberId,
'full_name_ar' => $tm['full_name_ar'],
'national_id' => $tm['national_id'] ?? null,
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
// Full membership transfer: move all dependents to new member
if ($isFullTransfer) {
$db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
......
......@@ -64,9 +64,9 @@
</div>
</div>
<!-- Graduation Date (documentation only, not used in calculation) -->
<div class="form-group">
<label class="form-label">تاريخ التخرج <span style="font-size:11px;color:#6B7280;">(توثيق فقط)</span></label>
<!-- Graduation Date (required when employed) -->
<div class="form-group" id="graduation-date-group">
<label class="form-label">تاريخ التخرج <span id="grad-required-mark" style="color:#DC2626;display:none;">*</span></label>
<input type="date" name="graduation_date" id="graduation_date" class="form-input">
</div>
......@@ -74,7 +74,7 @@
<div class="form-group" id="work-date-group" style="display:none;">
<label class="form-label">تاريخ مباشرة العمل <span style="color:#DC2626;">*</span></label>
<input type="date" name="work_date" id="work_date" class="form-input">
<small style="color:#6B7280;font-size:11px;">الفصل يتم عند أبكر التاريخين: العمل أو بلوغ الـ 25</small>
<small style="color:#6B7280;font-size:11px;">الفصل يتم عند أبكر التاريخين: اكتمال العمل والتخرج أو بلوغ الـ 25</small>
</div>
<!-- date_turned_25 — auto-computed, editable override -->
......@@ -101,6 +101,36 @@
</div>
</div>
<!-- Dependents for new membership (child_separation) -->
<div id="child-sep-dependents-section" style="display:none;">
<div class="card" style="margin-bottom:20px;border:2px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;background:#F5F3FF;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">إضافة تابعين للعضوية الجديدة</h3>
<p style="margin:5px 0 0;font-size:12px;color:#6B7280;">حدد التابعين الذين سيُضافون للعضوية الجديدة — سيتم احتساب اشتراكاتهم السنوية ضمن الرسوم</p>
</div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">عدد الزوجات</label>
<input type="number" name="dep_spouses_count" id="dep_spouses_count" class="form-input" value="0" min="0" max="4">
</div>
<div class="form-group">
<label class="form-label">عدد الأبناء</label>
<input type="number" name="dep_children_count" id="dep_children_count" class="form-input" value="0" min="0" max="20">
</div>
<div class="form-group">
<label class="form-label">عدد الأعضاء المؤقتين</label>
<input type="number" name="dep_temps_count" id="dep_temps_count" class="form-input" value="0" min="0" max="10">
</div>
</div>
<!-- Dynamic dependent detail rows -->
<div id="dep-details-container" style="padding:0 20px 20px;display:none;">
<div id="dep-spouses-details"></div>
<div id="dep-children-details"></div>
<div id="dep-temps-details"></div>
</div>
</div>
</div>
<!-- Recipient Data (for full_transfer) -->
<div id="recipient-section" style="display:none;">
<div class="card" style="margin-bottom:20px;border:2px solid #0D7377;">
......@@ -226,6 +256,7 @@
var recipientSection = document.getElementById('recipient-section');
var companionSection = document.getElementById('companion-section');
var childSeparationSection = document.getElementById('child-separation-section');
var childSepDepsSection = document.getElementById('child-sep-dependents-section');
var allOptions = Array.from(childSelect.querySelectorAll('option[data-age]'));
var targetSpouses = document.getElementById('target_spouses_count');
var targetChildren = document.getElementById('target_children_count');
......@@ -239,6 +270,8 @@
var isEmployedNo = document.getElementById('is_employed_no');
var workDateGroup = document.getElementById('work-date-group');
var workDateInput = document.getElementById('work_date');
var gradDateInput = document.getElementById('graduation_date');
var gradRequiredMark = document.getElementById('grad-required-mark');
var date25Input = document.getElementById('date_turned_25');
var date25Note = document.getElementById('date25-auto-note');
var effectiveDisplay = document.getElementById('effective_transfer_date_display');
......@@ -246,6 +279,12 @@
var elapsedGroup = document.getElementById('elapsed-years-group');
var elapsedDisplay = document.getElementById('elapsed-years-display');
// Dependents for new membership (child_separation)
var depSpousesCount = document.getElementById('dep_spouses_count');
var depChildrenCount = document.getElementById('dep_children_count');
var depTempsCount = document.getElementById('dep_temps_count');
var depDetailsContainer = document.getElementById('dep-details-container');
// child DOB lookup — keyed by child_id
var childDobMap = {};
<?php foreach ($children as $c): if (empty($c['date_of_birth'])) continue; ?>
......@@ -267,7 +306,6 @@
var dob = childDobMap[cid] || null;
if (!dob) { date25Input.value = ''; date25Note.style.display = 'none'; return; }
var d25 = addYears(dob, 25);
// Only auto-fill if user hasn't manually changed it
if (!date25Input.dataset.userOverride) {
date25Input.value = d25;
date25Note.style.display = 'block';
......@@ -278,25 +316,32 @@
function updateEffectiveDate() {
var d25 = date25Input.value;
var wDate = workDateInput.value;
var gDate = gradDateInput.value;
var emp = isEmployed();
workDateGroup.style.display = emp ? 'block' : 'none';
gradRequiredMark.style.display = emp ? 'inline' : 'none';
if (!d25) { effectiveDisplay.value = ''; effectiveBasis.textContent = ''; elapsedGroup.style.display = 'none'; return; }
var effective;
if (emp && wDate) {
effective = wDate < d25 ? wDate : d25;
effectiveBasis.textContent = wDate < d25
? 'الفصل يتم عند تاريخ العمل (أبكر من بلوغ 25)'
: 'الفصل يتم عند بلوغ 25 سنة (قبل أو مع العمل)';
if (emp && wDate && gDate) {
var conditionMet = wDate > gDate ? wDate : gDate;
effective = conditionMet < d25 ? conditionMet : d25;
if (conditionMet <= d25) {
effectiveBasis.textContent = 'الفصل يتم عند اكتمال العمل والتخرج (' + conditionMet + ')';
} else {
effectiveBasis.textContent = 'الفصل يتم عند بلوغ 25 سنة (أبكر من اكتمال الشرطين)';
}
} else if (emp) {
effective = d25;
effectiveBasis.textContent = 'أدخل تاريخ العمل وتاريخ التخرج لتحديد التاريخ الفعلي';
} else {
effective = d25;
effectiveBasis.textContent = emp ? 'أدخل تاريخ العمل لتحديد التاريخ الفعلي' : 'الفصل يتم عند بلوغ 25 سنة';
effectiveBasis.textContent = 'الفصل يتم عند بلوغ 25 سنة';
}
effectiveDisplay.value = effective;
// Show elapsed years info (UI only — server recalculates)
fetchFeePreview();
}
......@@ -308,6 +353,7 @@
childGroup.style.display = isChildType ? 'block' : 'none';
childSeparationSection.style.display = isChildSeparation ? 'block' : 'none';
childSepDepsSection.style.display = isChildSeparation ? 'block' : 'none';
recipientSection.style.display = isFullTransfer ? 'block' : 'none';
companionSection.style.display = isFullTransfer ? 'block' : 'none';
......@@ -352,6 +398,36 @@
}
}
function updateDepDetailRows() {
var spCount = parseInt(depSpousesCount.value || '0', 10);
var chCount = parseInt(depChildrenCount.value || '0', 10);
var tmCount = parseInt(depTempsCount.value || '0', 10);
var hasAny = (spCount + chCount + tmCount) > 0;
depDetailsContainer.style.display = hasAny ? 'block' : 'none';
document.getElementById('dep-spouses-details').innerHTML = buildDepRows('dep_spouses', spCount, 'زوجة', ['full_name_ar', 'national_id']);
document.getElementById('dep-children-details').innerHTML = buildDepRows('dep_children', chCount, 'ابن/ابنة', ['full_name_ar', 'national_id', 'gender']);
document.getElementById('dep-temps-details').innerHTML = buildDepRows('dep_temps', tmCount, 'عضو مؤقت', ['full_name_ar', 'national_id']);
fetchFeePreview();
}
function buildDepRows(prefix, count, label, fields) {
if (count === 0) return '';
var html = '<div style="margin-bottom:12px;"><strong style="font-size:13px;color:#7C3AED;">' + label + ' (' + count + ')</strong></div>';
for (var i = 0; i < count; i++) {
html += '<div style="display:flex;gap:10px;margin-bottom:8px;align-items:center;">';
html += '<span style="font-size:12px;color:#6B7280;min-width:20px;">' + (i+1) + '.</span>';
html += '<input type="text" name="' + prefix + '[' + i + '][full_name_ar]" class="form-input" style="flex:2;" placeholder="الاسم بالكامل *" required>';
html += '<input type="text" name="' + prefix + '[' + i + '][national_id]" class="form-input" style="flex:1;direction:ltr;text-align:left;" placeholder="الرقم القومي" maxlength="14">';
if (fields.indexOf('gender') !== -1) {
html += '<select name="' + prefix + '[' + i + '][gender]" class="form-select" style="flex:0.7;"><option value="male">ذكر</option><option value="female">أنثى</option></select>';
}
html += '</div>';
}
return html;
}
function fetchFeePreview() {
var type = transferType.value;
if (!type) {
......@@ -367,7 +443,11 @@
var emp = isEmployed() ? '1' : '0';
body += '&is_employed=' + emp;
if (workDateInput.value) body += '&work_date=' + encodeURIComponent(workDateInput.value);
if (gradDateInput.value) body += '&graduation_date=' + encodeURIComponent(gradDateInput.value);
if (date25Input.value) body += '&date_turned_25=' + encodeURIComponent(date25Input.value);
body += '&dep_spouses_count=' + (depSpousesCount.value || '0');
body += '&dep_children_count=' + (depChildrenCount.value || '0');
body += '&dep_temps_count=' + (depTempsCount.value || '0');
}
fetch('/api/transfers/calculate-fee', {
......@@ -418,6 +498,7 @@
isEmployedNo.addEventListener('change', updateEffectiveDate);
}
if (workDateInput) workDateInput.addEventListener('change', updateEffectiveDate);
if (gradDateInput) gradDateInput.addEventListener('change', updateEffectiveDate);
if (date25Input) {
date25Input.addEventListener('change', function() {
this.dataset.userOverride = '1';
......@@ -425,6 +506,11 @@
});
}
// Dependent count listeners (child_separation)
if (depSpousesCount) depSpousesCount.addEventListener('change', updateDepDetailRows);
if (depChildrenCount) depChildrenCount.addEventListener('change', updateDepDetailRows);
if (depTempsCount) depTempsCount.addEventListener('change', updateDepDetailRows);
// Recipient NID auto-parse
var rNid = document.getElementById('recipientNidInput');
var rDob = document.getElementById('recipientDobInput');
......
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$cols = [
'target_spouses_count' => "ALTER TABLE transfer_requests ADD COLUMN target_spouses_count INT UNSIGNED NULL DEFAULT NULL AFTER target_companions_count",
'target_children_count' => "ALTER TABLE transfer_requests ADD COLUMN target_children_count INT UNSIGNED NULL DEFAULT NULL AFTER target_spouses_count",
'target_temps_count' => "ALTER TABLE transfer_requests ADD COLUMN target_temps_count INT UNSIGNED NULL DEFAULT NULL AFTER target_children_count",
];
foreach ($cols as $col => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'transfer_requests' AND column_name = ?",
[$col]
);
if (!$exists) {
$db->raw($sql);
}
}
};
# Transfers Module — Architecture Map
> **Last updated:** 2026-06-10
> **Last updated:** 2026-07-21
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -83,7 +83,15 @@ app/Modules/Transfers/
| approved_at | timestamp | YES | | | |
| completed_at | timestamp | YES | | | |
| status | varchar(50) | NO | MUL | requested | requested/approved/fee_paid/completed/rejected |
| notes | text | YES | | | JSON (full_transfer stores recipient_data here) |
| is_employed | tinyint(1) | YES | | | Whether child is employed (child_separation) |
| graduation_date | date | YES | | | Graduation date (required when employed) |
| work_date | date | YES | | | Employment start date |
| date_turned_25 | date | YES | | | Calculated: child's DOB + 25 years |
| effective_transfer_date | date | YES | | | Computed: min(max(work,grad), date25) |
| target_spouses_count | int unsigned | YES | | | Dependents joining new membership (spouses) |
| target_children_count | int unsigned | YES | | | Dependents joining new membership (children) |
| target_temps_count | int unsigned | YES | | | Dependents joining new membership (temps) |
| notes | text | YES | | | JSON: full_transfer stores recipient_data; child_separation stores dependents_data |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | auto-update | |
| created_by | bigint unsigned | YES | | | FK to employees |
......@@ -110,16 +118,23 @@ requested → approved → fee_paid → completed
## 5. Core Business Flows
### 5.1 Child Separation Flow
### 5.1 Child Separation Flow (فصل أبناء)
```
1. GET /transfers/create/{memberId}
- Show active children and spouses for selection
- Show active children (filtered >= 25 for child_separation) and spouses
- Show employment status, graduation_date, work_date fields
- Show dependents section (spouses/children/temps joining new membership)
2. POST /transfers/store/{memberId}
- Validate: transfer_type, child_id required for child types
- Age check: child must be >= CHILD_MANDATORY_SEPARATION_AGE (25)
- Calculate fees via SeparationFeeCalculator::calculate()
- Validate: transfer_type, child_id, is_employed
- If employed: graduation_date AND work_date required
- Compute effective_transfer_date = min(max(work_date, graduation_date), date_turned_25)
- Read dep_spouses_count, dep_children_count, dep_temps_count
- Calculate fees via SeparationFeeCalculator::calculateForChildSeparation()
(annual subscription includes member + specified dependents)
- Collect dependent details (name, national_id) from form arrays
- Store in notes JSON as dependents_data
- Create TransferRequest record (status='requested')
- Submit to FormBridge (TRANSFER_SEPARATION form)
- Dispatch: transfer.requested
......@@ -139,9 +154,10 @@ requested → approved → fee_paid → completed
a. Take archive snapshot of source member
b. Archive source member (membership_number=NULL, status='transferred', is_archived=1)
c. Create new member with SAME membership_number
d. Mark child as 'separated' / move dependents for full_transfer
e. Record number chain via ArchiveService
f. Status → 'completed'
d. Mark child as 'separated'
e. Create dependents from notes.dependents_data (spouses, children, temps)
f. Record number chain via ArchiveService
g. Status → 'completed'
- Dispatch: transfer.completed
```
......@@ -166,7 +182,11 @@ Separation Fee = Current Membership Value × Fee Percentage (by years)
Year 6+: SEPARATION_FEE_YEAR_6_PLUS
Form Fee = FORM_TRANSFER_FEE rule (default 570 EGP)
Annual Subscription = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (default 492 + 35 = 527 EGP)
Annual Subscription (child_separation with dependents):
= SVC_ANNUAL_MEMBER + (spouses × SVC_ANNUAL_SPOUSE) + (children × SVC_ANNUAL_CHILD)
+ (temps × SVC_ANNUAL_TEMP) + DEVELOPMENT_FEE (once per family)
If activated after July 1 of current FY → 0 (already covered by membership fee)
Annual Subscription (legacy/other types) = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (492 + 35 = 527 EGP)
Companion Surcharge: if new owner brings MORE dependents than source had:
- Extra spouses: tiered (SPOUSE_2ND_FEE, SPOUSE_3RD_FEE, SPOUSE_4TH_FEE)
......@@ -259,6 +279,9 @@ Companion Surcharge: if new owner brings MORE dependents than source had:
| Service Catalog Codes | Purpose |
|----------------------|---------|
| SVC_ANNUAL_MEMBER | Annual member subscription base (492) |
| SVC_ANNUAL_SPOUSE | Annual spouse subscription (492) |
| SVC_ANNUAL_CHILD | Annual child subscription (222) |
| SVC_ANNUAL_TEMP | Annual temporary member subscription (222) |
| SVC_ADDITION_FORM | Addition form fee fallback (570) |
---
......
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