Commit ba9d9f81 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(waiver): prevent reconcile() from revoking membership after successful transfer

Root cause: after WaiverProcessor::execute() completed, visiting /members/{id}
triggered MembershipPaymentGuard::reconcile(), which did not recognise waiver_fee
as a valid activation payment. It stripped the membership_number and then called
deactivateAllDependents(), which crashed on spouses.join_date NOT NULL constraint.

Fixes:
- MembershipPaymentGuard::reconcile(): add waiver_fee path — looks up completed
  waiver_requests where target_member_id matches, preventing false deactivation
- MembershipPaymentGuard::deactivateAllDependents(): spouses.join_date is NOT NULL;
  use sentinel date '1970-01-01' instead of NULL to avoid constraint violation
- MembershipPaymentGuard::deactivateDependent(): same sentinel fix for spouses
- WaiverProcessor::execute(): set activated_by_payment_id + activated_at on the
  target member so the existing fallback check in reconcile() catches future cases

Also restored member #136 directly in DB (membership_number='1600', status='active',
activated_by_payment_id=773) which was the live victim of this bug.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 910e8606
...@@ -158,9 +158,11 @@ final class MembershipPaymentGuard ...@@ -158,9 +158,11 @@ final class MembershipPaymentGuard
return ['success' => true, 'included_in_membership' => true]; return ['success' => true, 'included_in_membership' => true];
} }
// spouses.join_date is NOT NULL — use sentinel date instead of NULL
$joinDateValue = ($table === 'spouses') ? '1970-01-01' : null;
$db->update($table, [ $db->update($table, [
'status' => 'pending_payment', 'status' => 'pending_payment',
'join_date' => null, 'join_date' => $joinDateValue,
'activated_by_payment_id'=> null, 'activated_by_payment_id'=> null,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]); ], '`id` = ?', [$entityId]);
...@@ -201,11 +203,20 @@ final class MembershipPaymentGuard ...@@ -201,11 +203,20 @@ final class MembershipPaymentGuard
/** /**
* Deactivate ALL dependents for a member (used when member itself is deactivated). * Deactivate ALL dependents for a member (used when member itself is deactivated).
*
* spouses.join_date is NOT NULL in the DB — we cannot SET it to NULL directly.
* Use a sentinel date ('1970-01-01') to mark deactivated rows instead of NULL.
* children and temporary_members allow NULL but we use the same approach for consistency.
*/ */
private static function deactivateAllDependents(int $memberId): void private static function deactivateAllDependents(int $memberId): void
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
foreach (['spouses', 'children', 'temporary_members'] as $table) { // spouses.join_date is NOT NULL — use sentinel date rather than NULL
$db->query(
"UPDATE `spouses` SET status = 'pending_payment', join_date = '1970-01-01', activated_by_payment_id = NULL, updated_at = NOW() WHERE member_id = ? AND status IN ('active','frozen') AND is_archived = 0",
[$memberId]
);
foreach (['children', 'temporary_members'] as $table) {
$db->query( $db->query(
"UPDATE `{$table}` SET status = 'pending_payment', join_date = NULL, activated_by_payment_id = NULL, updated_at = NOW() WHERE member_id = ? AND status IN ('active','frozen') AND is_archived = 0", "UPDATE `{$table}` SET status = 'pending_payment', join_date = NULL, activated_by_payment_id = NULL, updated_at = NOW() WHERE member_id = ? AND status IN ('active','frozen') AND is_archived = 0",
[$memberId] [$memberId]
...@@ -257,7 +268,7 @@ final class MembershipPaymentGuard ...@@ -257,7 +268,7 @@ final class MembershipPaymentGuard
[$memberId] [$memberId]
); );
// Also check if this member was activated via death transfer or membership transfer // Check if activated via death transfer
if (!$hasValidPayment) { if (!$hasValidPayment) {
$hasValidPayment = $db->selectOne( $hasValidPayment = $db->selectOne(
"SELECT p.id FROM payments p "SELECT p.id FROM payments p
...@@ -267,7 +278,17 @@ final class MembershipPaymentGuard ...@@ -267,7 +278,17 @@ final class MembershipPaymentGuard
); );
} }
// Check if activated_by_payment_id references a valid non-voided payment // Check if activated via waiver transfer (waiver_fee paid by this member as target)
if (!$hasValidPayment) {
$hasValidPayment = $db->selectOne(
"SELECT p.id FROM payments p
JOIN waiver_requests wr ON wr.id = p.related_entity_id AND p.related_entity_type = 'waiver_requests'
WHERE wr.target_member_id = ? AND wr.status = 'completed' AND p.payment_type = 'waiver_fee' AND p.is_voided = 0 LIMIT 1",
[$memberId]
);
}
// Check if activated_by_payment_id references a valid non-voided payment of any type
if (!$hasValidPayment && $member['activated_by_payment_id']) { if (!$hasValidPayment && $member['activated_by_payment_id']) {
$hasValidPayment = $db->selectOne( $hasValidPayment = $db->selectOne(
"SELECT id FROM payments WHERE id = ? AND is_voided = 0 LIMIT 1", "SELECT id FROM payments WHERE id = ? AND is_voided = 0 LIMIT 1",
......
...@@ -34,6 +34,13 @@ final class WaiverProcessor ...@@ -34,6 +34,13 @@ final class WaiverProcessor
try { try {
$snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId); $snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId);
// Find the waiver_fee payment made for this transfer (to set as activated_by_payment_id)
$waiverPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'waiver_fee' AND related_entity_type = 'waiver_requests' AND related_entity_id = ? AND is_voided = 0 ORDER BY id DESC LIMIT 1",
[(int) $waiver['target_member_id'], $waiverId]
);
$waiverPaymentId = $waiverPayment ? (int) $waiverPayment['id'] : null;
$db->update('members', [ $db->update('members', [
'membership_number' => null, 'membership_number' => null,
'status' => 'waived', 'status' => 'waived',
...@@ -47,6 +54,8 @@ final class WaiverProcessor ...@@ -47,6 +54,8 @@ final class WaiverProcessor
'membership_number' => $waiver['membership_number'], 'membership_number' => $waiver['membership_number'],
'status' => 'active', 'status' => 'active',
'membership_type' => $sourceMember['membership_type'] ?? 'working', 'membership_type' => $sourceMember['membership_type'] ?? 'working',
'activated_by_payment_id' => $waiverPaymentId,
'activated_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'),
], '`id` = ?', [(int) $waiver['target_member_id']]); ], '`id` = ?', [(int) $waiver['target_member_id']]);
......
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