Commit 5a5aba46 authored by Fares's avatar Fares

fix(subscriptions): prevent duplicate subscription rows with unique constraint

Root causes of duplicate members in yearly subscriptions:
1. No DB-level unique constraint allowed race conditions between
   SubscriptionGenerator, SyncService, and RetroactiveMembershipService
2. SyncService set person_id=NULL for member rows vs Generator's person_id=memberId
3. RetroactiveMembershipService did blind INSERTs with no dedup check

Fix:
- Migration removes existing duplicates (keeps paid row, lowest ID tiebreak)
- Normalizes NULL person_id on member rows
- Adds UNIQUE INDEX (member_id, financial_year, person_type, person_id)
- All insert paths catch Duplicate entry exceptions as race guard
- SyncService now sets person_id=memberId matching Generator
- RetroactiveMembershipService checks for existing row before INSERT
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent a8c964ee
......@@ -695,6 +695,17 @@ final class RetroactiveMembershipService
if (!preg_match('/^\d{4}$/', $yearStart)) $yearStart = date('Y');
$personType = $sub['person_type'] ?? 'member';
$personId = (int) ($sub['person_id'] ?? 0);
// Dedup guard: skip if subscription already exists for this combination
$existing = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = ? AND person_id = ?",
[$memberId, $financialYear, $personType, $personId]
);
if ($existing) {
return (int) $existing['id'];
}
$baseAmount = (string) ($sub['base_amount'] ?? '0.00');
$devFee = ($personType === 'member') ? '35.00' : '0.00';
......@@ -729,7 +740,7 @@ final class RetroactiveMembershipService
'member_id' => $memberId,
'financial_year' => $financialYear,
'person_type' => $personType,
'person_id' => (int) $sub['person_id'],
'person_id' => $personId,
'person_name' => $sub['person_name'] ?? '',
'base_amount' => $baseAmount,
'development_fee' => $devFee,
......
......@@ -76,6 +76,7 @@ final class SubscriptionGenerator
// Member subscription — discount on base only; dev fee is separate non-discountable charge
$discount = $discountPct ? bcdiv(bcmul($memberRate, $discountPct, 4), '100', 2) : '0.00';
$total = bcsub($memberRate, $discount, 2);
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -92,6 +93,9 @@ final class SubscriptionGenerator
'created_by' => $empId,
]);
$created++;
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) { $skipped++; continue; } else { throw $e; }
}
// Spouses — NO dev fee
if ($db->tableExists('spouses')) {
......@@ -104,6 +108,7 @@ final class SubscriptionGenerator
if ($existingSp) { $skipped++; continue; }
$spDiscount = $discountPct ? bcdiv(bcmul($spouseRate, $discountPct, 4), '100', 2) : '0.00';
$spTotal = bcsub($spouseRate, $spDiscount, 2);
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -120,6 +125,9 @@ final class SubscriptionGenerator
'created_by' => $empId,
]);
$created++;
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) { $skipped++; } else { throw $e; }
}
}
}
......@@ -134,6 +142,7 @@ final class SubscriptionGenerator
if ($existingCh) { $skipped++; continue; }
$chDiscount = $discountPct ? bcdiv(bcmul($childRate, $discountPct, 4), '100', 2) : '0.00';
$chTotal = bcsub($childRate, $chDiscount, 2);
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -150,6 +159,9 @@ final class SubscriptionGenerator
'created_by' => $empId,
]);
$created++;
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) { $skipped++; } else { throw $e; }
}
}
}
......@@ -164,6 +176,7 @@ final class SubscriptionGenerator
if ($existingTmp) { $skipped++; continue; }
$tmpDiscount = $discountPct ? bcdiv(bcmul($tempRate, $discountPct, 4), '100', 2) : '0.00';
$tmpTotal = bcsub($tempRate, $tmpDiscount, 2);
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -180,6 +193,9 @@ final class SubscriptionGenerator
'created_by' => $empId,
]);
$created++;
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) { $skipped++; } else { throw $e; }
}
}
}
}
......
......@@ -46,8 +46,8 @@ final class SubscriptionSyncService
// ── Member's own subscription row ─────────────────────────────
$existing = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'member'",
[$memberId, $fy]
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'member' AND (person_id = ? OR person_id IS NULL)",
[$memberId, $fy, $memberId]
);
if (!$existing) {
$rateMap = [
......@@ -69,11 +69,12 @@ final class SubscriptionSyncService
$devFeeRule = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = (string) ($devFeeRule['amount'] ?? '35.00');
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $fy,
'person_type' => 'member',
'person_id' => null,
'person_id' => $memberId,
'person_name' => $member['full_name_ar'],
'base_amount' => $rate,
'development_fee' => $devFee,
......@@ -85,6 +86,13 @@ final class SubscriptionSyncService
'created_by' => $empId,
]);
Logger::info("SubscriptionSyncService: added member #{$memberId} to FY {$fy}");
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
Logger::info("SubscriptionSyncService: member #{$memberId} FY {$fy} already exists (race guard)");
} else {
throw $e;
}
}
}
// ── All active dependents ─────────────────────────────────────
......@@ -183,6 +191,7 @@ final class SubscriptionSyncService
$empId = $employee ? (int) $employee->id : null;
$ts = date('Y-m-d H:i:s');
try {
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $fy,
......@@ -198,8 +207,14 @@ final class SubscriptionSyncService
'updated_at' => $ts,
'created_by' => $empId,
]);
Logger::info("SubscriptionSyncService: added {$personType} #{$personId} to FY {$fy} for member #{$memberId}");
} catch (\Throwable $insertErr) {
if (str_contains($insertErr->getMessage(), 'Duplicate entry')) {
Logger::info("SubscriptionSyncService: {$personType} #{$personId} FY {$fy} already exists (race guard)");
} else {
throw $insertErr;
}
}
} catch (\Throwable $e) {
Logger::error("SubscriptionSyncService::syncForDependent failed: " . $e->getMessage(), [
'person_type' => $personType,
......
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
// Phase 1: Remove duplicate subscription rows.
// For each (member_id, financial_year, person_type, person_id) group with more than one row,
// keep the "best" row (paid > exempt > overdue > pending) with the lowest ID as tiebreaker.
// Delete the rest.
$duplicates = $db->select("
SELECT member_id, financial_year, person_type, person_id, COUNT(*) as cnt
FROM subscriptions
GROUP BY member_id, financial_year, person_type, person_id
HAVING cnt > 1
");
$deleted = 0;
foreach ($duplicates as $dup) {
$personIdCondition = $dup['person_id'] === null
? 'person_id IS NULL'
: 'person_id = ' . (int) $dup['person_id'];
$rows = $db->select("
SELECT id, status, paid_amount, payment_id
FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND person_type = ? AND {$personIdCondition}
ORDER BY
FIELD(status, 'paid', 'exempt', 'overdue', 'pending') ASC,
CASE WHEN paid_amount > 0 THEN 0 ELSE 1 END ASC,
id ASC
", [(int) $dup['member_id'], $dup['financial_year'], $dup['person_type']]);
// Keep the first row (best status), delete the rest
$keepId = (int) $rows[0]['id'];
foreach (array_slice($rows, 1) as $extra) {
$db->delete('subscriptions', 'id = ?', [(int) $extra['id']]);
$deleted++;
}
}
// Phase 2: Handle NULL person_id rows — normalize to avoid unique constraint issues.
// The SubscriptionGenerator sets person_id = member_id for person_type='member',
// but SubscriptionSyncService sets person_id = NULL. Normalize to match the generator.
$db->query("
UPDATE subscriptions
SET person_id = member_id
WHERE person_type = 'member' AND person_id IS NULL
");
// Phase 3: Re-check duplicates after normalization (NULL->member_id may have created new dupes)
$duplicates2 = $db->select("
SELECT member_id, financial_year, person_type, person_id, COUNT(*) as cnt
FROM subscriptions
GROUP BY member_id, financial_year, person_type, person_id
HAVING cnt > 1
");
foreach ($duplicates2 as $dup) {
$rows = $db->select("
SELECT id, status, paid_amount, payment_id
FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND person_type = ? AND person_id = ?
ORDER BY
FIELD(status, 'paid', 'exempt', 'overdue', 'pending') ASC,
CASE WHEN paid_amount > 0 THEN 0 ELSE 1 END ASC,
id ASC
", [(int) $dup['member_id'], $dup['financial_year'], $dup['person_type'], (int) $dup['person_id']]);
$keepId = (int) $rows[0]['id'];
foreach (array_slice($rows, 1) as $extra) {
$db->delete('subscriptions', 'id = ?', [(int) $extra['id']]);
$deleted++;
}
}
// Phase 4: Add unique constraint to prevent future duplicates
$db->raw("
ALTER TABLE subscriptions
ADD UNIQUE INDEX uq_subscription_member_year_person (member_id, financial_year, person_type, person_id)
");
};
......@@ -135,6 +135,20 @@ service codes. All 204 initially generated subscriptions had `base_amount=0.00`.
2. Hard-coded fallback rates as last resort: member/spouse=492, child/temporary=222
3. All 204 bad rows manually corrected via UPDATE on production DB.
**Fix applied 2026-07-21 (duplicate subscription rows):** Multiple root causes identified:
1. No DB-level UNIQUE constraint — race conditions between SubscriptionGenerator, SyncService, and
RetroactiveMembershipService could create duplicate rows.
2. SyncService set `person_id = NULL` for member rows while Generator set `person_id = $memberId`
SELECT dedup guards wouldn't match across systems.
3. RetroactiveMembershipService had zero dedup checks (blind INSERT).
Fix:
- Migration Phase_97_001 removes all existing duplicates (keeps paid > overdue > pending, lowest ID tiebreak)
- Normalizes NULL person_id on member rows to member_id
- Adds UNIQUE INDEX on (member_id, financial_year, person_type, person_id)
- All insert paths now catch Duplicate entry exceptions as race-condition guard
- SyncService now sets person_id = memberId for member rows (matches Generator)
- RetroactiveMembershipService now checks for existing row before INSERT
### 5.1b Real-Time Member + Dependent Sync (SubscriptionSyncService) — Added 2026-07-18
When a member activates or a dependent is added, subscription rows are automatically inserted
......@@ -385,6 +399,8 @@ No subscriptions should exist before this year.
- If interrupted mid-batch: some members have subscriptions, others don't
- No rollback mechanism for partial generation
- Multiple years can be generated (retroactive via manual batch-generate form)
- **UNIQUE INDEX (member_id, financial_year, person_type, person_id)** prevents duplicates at DB level (added 2026-07-21)
- All insert paths wrap in try/catch for Duplicate entry — safe even under race conditions
### 13.2 Fine Calculation Consistency
- Fine is calculated on `total_amount` (full subscription), not remaining unpaid
......
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