Commit db92e175 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(registration): prevent double form-fee charge + NID-swap exploit + profile actions

Critical fixes:
- submitFormPayment() now checks sa_players.registration_fee_paid as
  a safety net before creating any payment request
- startRegistration() member_id fallback only used when NID matches
  the member's actual national_id (prevents NID-swap fee bypass)
- lookupPlayer() validates NID-to-member consistency before proceeding
- Existing registration lookup now includes 'pending_payment' status
- Frontend clears membership_number when NID is manually changed
  after auto-fill

Feature:
- Player profile page now shows "Print Form" and "Generate Card"
  buttons for completing registration steps outside the wizard
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 6b176d1f
......@@ -319,6 +319,13 @@ class PlayerController extends Controller
);
}
$printableRegistration = $db->selectOne(
"SELECT id FROM sa_registrations
WHERE player_id = ? AND status != 'cancelled' AND form_payment_status = 'paid'
ORDER BY id DESC LIMIT 1",
[(int) $id]
);
return $this->view('SportsActivity.Views.players.show', [
'player' => $player,
'documents' => $documents,
......@@ -330,6 +337,7 @@ class PlayerController extends Controller
'gateHistory' => $gateHistory,
'activeCard' => $activeCard,
'paidRegistration' => $paidRegistration,
'printableRegistration' => $printableRegistration,
]);
}
......
......@@ -78,6 +78,15 @@ class RegistrationWizardController extends Controller
}
}
if ($memberId > 0 && $nationalId !== '' && strlen($nationalId) === 14 && $membership && !empty($membership['member'])) {
$memberNid = $membership['member']['national_id'] ?? '';
if ($memberNid !== '' && $memberNid !== $nationalId) {
$playerType = 'non_member';
$memberId = 0;
$membershipReason = 'الرقم القومي لا يطابق بيانات العضو';
}
}
if ($fullNameAr === '' && $nidParsed === null) {
return $this->json(['success' => false, 'error' => 'أدخل رقم العضوية أو الرقم القومي أو الاسم']);
}
......@@ -88,7 +97,7 @@ class RegistrationWizardController extends Controller
$existingReg = $db->selectOne(
"SELECT r.id, r.status, r.card_generated FROM sa_registrations r
INNER JOIN sa_players p ON p.id = r.player_id
WHERE p.national_id = ? AND r.status IN ('in_progress','completed','assessed') AND p.is_archived = 0
WHERE p.national_id = ? AND r.status IN ('in_progress','pending_payment','completed','assessed') AND p.is_archived = 0
ORDER BY r.id DESC LIMIT 1",
[$nationalId]
);
......@@ -97,7 +106,7 @@ class RegistrationWizardController extends Controller
$existingReg = $db->selectOne(
"SELECT r.id, r.status, r.card_generated FROM sa_registrations r
INNER JOIN sa_players p ON p.id = r.player_id
WHERE p.member_id = ? AND r.status IN ('in_progress','completed','assessed') AND p.is_archived = 0
WHERE p.member_id = ? AND r.status IN ('in_progress','pending_payment','completed','assessed') AND p.is_archived = 0
ORDER BY r.id DESC LIMIT 1",
[$memberId]
);
......
......@@ -47,11 +47,21 @@ final class RegistrationWizardService
);
}
if (!$existingPlayer && $playerType === 'member' && $memberId > 0) {
$shouldUseMemberFallback = ($nationalId === '');
if (!$shouldUseMemberFallback) {
$memberRecord = $db->selectOne(
"SELECT national_id FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
$shouldUseMemberFallback = $memberRecord && $memberRecord['national_id'] === $nationalId;
}
if ($shouldUseMemberFallback) {
$existingPlayer = $db->selectOne(
"SELECT * FROM sa_players WHERE member_id = ? AND is_archived = 0",
[$memberId]
);
}
}
if ($existingPlayer) {
$playerId = (int) $existingPlayer['id'];
......@@ -309,6 +319,19 @@ final class RegistrationWizardService
return ['success' => true, 'already_paid' => true];
}
$playerFeeCheck = $db->selectOne(
"SELECT registration_fee_paid FROM sa_players WHERE id = ?",
[(int) $registration['player_id']]
);
if ($playerFeeCheck && (int) ($playerFeeCheck['registration_fee_paid'] ?? 0) === 1) {
$db->update('sa_registrations', [
'form_payment_status' => 'paid',
'registration_fee' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$registrationId]);
return ['success' => true, 'already_paid' => true];
}
$totalFee = (float) $registration['total_fees'];
if ($totalFee <= 0) {
$totalFee = $registration['player_type'] === 'member' ? 50.0 : 100.0;
......
......@@ -7,7 +7,10 @@ $__template->layout('Layout.main');
<?php if (!empty($activeCard) && can('sa.card.print')): ?>
<a href="/sa/cards/<?= (int) $activeCard['id'] ?>/print" target="_blank" class="btn btn-primary" style="background:#059669;border-color:#059669;"><i data-lucide="printer" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> طباعة الكارت</a>
<?php elseif (!empty($paidRegistration) && can('sa.registration.manage')): ?>
<a href="/sa/registration/<?= (int) $paidRegistration['id'] ?>" class="btn btn-outline" style="color:#2563EB;border-color:#2563EB;"><i data-lucide="credit-card" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء الكارت</a>
<form method="POST" action="/sa/registration/<?= (int) $paidRegistration['id'] ?>/generate-card" style="display:inline;"><?= csrf_field() ?><button type="submit" class="btn btn-primary" style="background:#059669;border-color:#059669;"><i data-lucide="credit-card" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء الكارت</button></form>
<?php endif; ?>
<?php if (!empty($printableRegistration) && can('sa.registration.manage')): ?>
<a href="/sa/registration/<?= (int) $printableRegistration['id'] ?>/print-form" target="_blank" class="btn btn-outline"><i data-lucide="file-text" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> طباعة الاستمارة</a>
<?php endif; ?>
<?php if (can('sa.player.manage')): ?>
<a href="/sa/players/<?= (int) $player['id'] ?>/edit" class="btn btn-primary"><i data-lucide="edit-3" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> تعديل</a>
......
......@@ -314,11 +314,14 @@ document.addEventListener('DOMContentLoaded', function() {
var regNameAr = document.getElementById('regNameAr');
var regPhone = document.getElementById('regPhone');
var autoFilledNid = '';
function fillMemberData(data) {
if (data.name) regNameAr.value = data.name;
if (data.phone && regPhone && !regPhone.value.trim()) regPhone.value = data.phone;
if (data.national_id && !regNid.value) regNid.value = data.national_id;
if (data.membership_number && !regMembershipNo.value) regMembershipNo.value = data.membership_number;
if (data.national_id) autoFilledNid = data.national_id;
}
var regMemberTimer = null;
......@@ -363,6 +366,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (regNid) {
regNid.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '');
if (autoFilledNid !== '' && this.value !== autoFilledNid) {
regMembershipNo.value = '';
memberLookupBox.style.display = 'none';
autoFilledNid = '';
}
if (this.value.length === 14) {
fetch('/api/sa/member-lookup', {
method: 'POST',
......
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