Commit 9df6ed31 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(subscriptions): enforce 2023/2024 as first FY, apply 50% discount, full rewrite

- Remove unpaid subscriptions before 2023/2024 (system didn't exist)
- Apply 50% discount to all 2023/2024 subscriptions
- Fix amounts/discounts for all years based on historical rate table
- Reset and recalculate all fines from scratch
- Delete orphaned subscription rows with no matching dependent
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c0da96c3
...@@ -12,21 +12,31 @@ final class SubscriptionDataMigration ...@@ -12,21 +12,31 @@ final class SubscriptionDataMigration
private Database $db; private Database $db;
private string $currentFY = '2026/2027'; private string $currentFY = '2026/2027';
private int $currentFYStart = 2026; private int $currentFYStart = 2026;
private int $firstFYStart = 2023; // Subscription system started 2023/2024
private string $ts; private string $ts;
// Base rates per year (before discount)
private array $rates = [ private array $rates = [
'2023/2024' => ['member' => '410.00', 'spouse' => '410.00', 'child' => '185.00', 'temporary' => '185.00'], '2023/2024' => ['member' => '410.00', 'spouse' => '410.00', 'child' => '185.00', 'temporary' => '185.00'],
'2024/2025' => ['member' => '410.00', 'spouse' => '410.00', 'child' => '185.00', 'temporary' => '185.00'], '2024/2025' => ['member' => '410.00', 'spouse' => '410.00', 'child' => '185.00', 'temporary' => '185.00'],
]; ];
private array $defaultRates = ['member' => '492.00', 'spouse' => '492.00', 'child' => '222.00', 'temporary' => '222.00']; private array $defaultRates = ['member' => '492.00', 'spouse' => '492.00', 'child' => '222.00', 'temporary' => '222.00'];
// Year-specific discounts (percentage applied to base rate)
private array $yearDiscounts = [
'2023/2024' => '50', // 50% discount
];
private string $devFee = '35.00'; private string $devFee = '35.00';
private array $stats = [ private array $stats = [
'members_processed' => 0, 'members_processed' => 0,
'pre_2023_deleted' => 0,
'subscriptions_created' => 0, 'subscriptions_created' => 0,
'orphaned_ids_fixed' => 0, 'orphaned_ids_fixed' => 0,
'duplicates_removed' => 0, 'duplicates_removed' => 0,
'amount_corrections' => 0, 'amount_corrections' => 0,
'discount_corrections' => 0,
'fines_applied' => 0, 'fines_applied' => 0,
'errors' => [], 'errors' => [],
]; ];
...@@ -41,145 +51,119 @@ final class SubscriptionDataMigration ...@@ -41,145 +51,119 @@ final class SubscriptionDataMigration
{ {
Logger::info("SubscriptionDataMigration started", ['dry_run' => $dryRun]); Logger::info("SubscriptionDataMigration started", ['dry_run' => $dryRun]);
// Phase 0: Remove subscriptions before the system start (2023/2024)
$this->removePreSystemSubscriptions($dryRun);
// Phase 1: Fix orphaned person_id references // Phase 1: Fix orphaned person_id references
$this->fixOrphanedPersonIds($dryRun); $this->fixOrphanedPersonIds($dryRun);
// Phase 2: Remove exact duplicates // Phase 2: Remove exact duplicates
$this->removeDuplicates($dryRun); $this->removeDuplicates($dryRun);
// Phase 3: Fix incorrect amounts // Phase 3: Fix incorrect amounts and apply 2023/2024 discount
$this->fixIncorrectAmounts($dryRun); $this->fixAmountsAndDiscounts($dryRun);
// Phase 4: Create missing subscriptions for all members and dependents // Phase 4: Create missing subscriptions for all members and dependents
$this->createMissingSubscriptions($dryRun); $this->createMissingSubscriptions($dryRun);
// Phase 5: Apply overdue fines // Phase 5: Reset and reapply overdue fines
if (!$dryRun) { if (!$dryRun) {
$this->applyFines(); $this->resetAndApplyFines();
} }
Logger::info("SubscriptionDataMigration completed", $this->stats); Logger::info("SubscriptionDataMigration completed", $this->stats);
return $this->stats; return $this->stats;
} }
private function fixOrphanedPersonIds(bool $dryRun): void private function removePreSystemSubscriptions(bool $dryRun): void
{ {
// Find spouse subscriptions where person_id doesn't match any spouse for that member // Count rows before first FY
$orphanedSpouses = $this->db->select( $count = $this->db->selectOne(
"SELECT s.id, s.member_id, s.person_id, s.person_name, s.financial_year "SELECT COUNT(*) as cnt FROM subscriptions WHERE financial_year < ?",
FROM subscriptions s [$this->firstFYStart . '/' . ($this->firstFYStart + 1)]
WHERE s.person_type = 'spouse'
AND s.person_id NOT IN (
SELECT sp.id FROM spouses sp WHERE sp.member_id = s.member_id
)"
); );
$cnt = (int)($count['cnt'] ?? 0);
foreach ($orphanedSpouses as $os) { if ($cnt > 0 && !$dryRun) {
// Try to match by name (check active first, then archived) // Only delete unpaid pre-system subscriptions; keep paid ones as historical record
$match = $this->db->selectOne( $this->db->query(
"SELECT id FROM spouses WHERE member_id = ? AND full_name_ar = ? ORDER BY is_archived ASC LIMIT 1", "DELETE FROM subscriptions WHERE financial_year < ? AND status != 'paid'",
[(int)$os['member_id'], $os['person_name']] [$this->firstFYStart . '/' . ($this->firstFYStart + 1)]
); );
if (!$match) { $deletedCount = $this->db->selectOne(
// Try partial match or just get first unmatched spouse "SELECT ROW_COUNT() as cnt"
$existingIds = $this->db->select(
"SELECT DISTINCT person_id FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND person_type = 'spouse' AND id != ?",
[(int)$os['member_id'], $os['financial_year'], (int)$os['id']]
); );
$usedIds = array_column($existingIds, 'person_id'); $this->stats['pre_2023_deleted'] = (int)($deletedCount['cnt'] ?? $cnt);
} else {
$availableSpouse = $this->db->selectOne( $unpaid = $this->db->selectOne(
"SELECT id FROM spouses WHERE member_id = ? AND is_archived = 0 AND id NOT IN (" . "SELECT COUNT(*) as cnt FROM subscriptions WHERE financial_year < ? AND status != 'paid'",
(empty($usedIds) ? '0' : implode(',', array_map('intval', $usedIds))) . ") LIMIT 1", [$this->firstFYStart . '/' . ($this->firstFYStart + 1)]
[(int)$os['member_id']]
); );
$match = $availableSpouse; $this->stats['pre_2023_deleted'] = (int)($unpaid['cnt'] ?? 0);
}
if ($match && !$dryRun) {
$this->db->update('subscriptions',
['person_id' => (int)$match['id'], 'updated_at' => $this->ts],
'id = ?', [(int)$os['id']]
);
$this->stats['orphaned_ids_fixed']++;
} elseif ($match) {
$this->stats['orphaned_ids_fixed']++;
} }
} }
// Same for children private function fixOrphanedPersonIds(bool $dryRun): void
$orphanedChildren = $this->db->select( {
$types = [
'spouse' => 'spouses',
'child' => 'children',
'temporary' => 'temporary_members',
];
foreach ($types as $personType => $table) {
$orphaned = $this->db->select(
"SELECT s.id, s.member_id, s.person_id, s.person_name, s.financial_year "SELECT s.id, s.member_id, s.person_id, s.person_name, s.financial_year
FROM subscriptions s FROM subscriptions s
WHERE s.person_type = 'child' WHERE s.person_type = ?
AND s.person_id NOT IN ( AND NOT EXISTS (SELECT 1 FROM {$table} t WHERE t.id = s.person_id AND t.member_id = s.member_id)",
SELECT ch.id FROM children ch WHERE ch.member_id = s.member_id [$personType]
)"
); );
foreach ($orphanedChildren as $oc) { foreach ($orphaned as $row) {
// Match by name (includes archived records)
$match = $this->db->selectOne( $match = $this->db->selectOne(
"SELECT id FROM children WHERE member_id = ? AND full_name_ar = ? ORDER BY is_archived ASC LIMIT 1", "SELECT id FROM {$table} WHERE member_id = ? AND full_name_ar = ? ORDER BY is_archived ASC LIMIT 1",
[(int)$oc['member_id'], $oc['person_name']] [(int)$row['member_id'], $row['person_name']]
); );
if (!$match) { if (!$match) {
// Get first unused dependent of this type for this member+FY
$existingIds = $this->db->select( $existingIds = $this->db->select(
"SELECT DISTINCT person_id FROM subscriptions "SELECT DISTINCT person_id FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND person_type = 'child' AND id != ?", WHERE member_id = ? AND financial_year = ? AND person_type = ? AND id != ?",
[(int)$oc['member_id'], $oc['financial_year'], (int)$oc['id']] [(int)$row['member_id'], $row['financial_year'], $personType, (int)$row['id']]
); );
$usedIds = array_column($existingIds, 'person_id'); $usedIds = array_column($existingIds, 'person_id');
$availableChild = $this->db->selectOne( $match = $this->db->selectOne(
"SELECT id FROM children WHERE member_id = ? AND is_archived = 0 AND id NOT IN (" . "SELECT id FROM {$table} WHERE member_id = ? AND id NOT IN (" .
(empty($usedIds) ? '0' : implode(',', array_map('intval', $usedIds))) . ") LIMIT 1", (empty($usedIds) ? '0' : implode(',', array_map('intval', $usedIds))) . ") LIMIT 1",
[(int)$oc['member_id']] [(int)$row['member_id']]
); );
$match = $availableChild;
} }
if ($match && !$dryRun) { if ($match && !$dryRun) {
$this->db->update('subscriptions', $this->db->update('subscriptions',
['person_id' => (int)$match['id'], 'updated_at' => $this->ts], ['person_id' => (int)$match['id'], 'updated_at' => $this->ts],
'id = ?', [(int)$oc['id']] 'id = ?', [(int)$row['id']]
); );
$this->stats['orphaned_ids_fixed']++;
} elseif ($match) {
$this->stats['orphaned_ids_fixed']++;
} }
} if ($match) {
// Same for temporary members
$orphanedTemps = $this->db->select(
"SELECT s.id, s.member_id, s.person_id, s.person_name, s.financial_year
FROM subscriptions s
WHERE s.person_type = 'temporary'
AND s.person_id NOT IN (
SELECT t.id FROM temporary_members t WHERE t.member_id = s.member_id
)"
);
foreach ($orphanedTemps as $ot) {
$match = $this->db->selectOne(
"SELECT id FROM temporary_members WHERE member_id = ? AND full_name_ar = ? ORDER BY is_archived ASC LIMIT 1",
[(int)$ot['member_id'], $ot['person_name']]
);
if ($match && !$dryRun) {
$this->db->update('subscriptions',
['person_id' => (int)$match['id'], 'updated_at' => $this->ts],
'id = ?', [(int)$ot['id']]
);
$this->stats['orphaned_ids_fixed']++; $this->stats['orphaned_ids_fixed']++;
} elseif ($match) { } else {
// No matching dependent exists — delete orphan subscription
if (!$dryRun) {
$this->db->query("DELETE FROM subscriptions WHERE id = ?", [(int)$row['id']]);
}
$this->stats['orphaned_ids_fixed']++; $this->stats['orphaned_ids_fixed']++;
} }
} }
} }
}
private function removeDuplicates(bool $dryRun): void private function removeDuplicates(bool $dryRun): void
{ {
// Find exact duplicates: same member_id, financial_year, person_type, person_id
$duplicates = $this->db->select( $duplicates = $this->db->select(
"SELECT member_id, financial_year, person_type, person_id, COUNT(*) as cnt, MIN(id) as keep_id "SELECT member_id, financial_year, person_type, person_id, COUNT(*) as cnt, MIN(id) as keep_id
FROM subscriptions FROM subscriptions
...@@ -199,50 +183,44 @@ final class SubscriptionDataMigration ...@@ -199,50 +183,44 @@ final class SubscriptionDataMigration
} }
} }
private function fixIncorrectAmounts(bool $dryRun): void private function fixAmountsAndDiscounts(bool $dryRun): void
{ {
// Fix subscriptions with 0.00 base_amount that should have real rates // For each FY, ensure correct base_amount, discount_amount, and total_amount
$zeroRows = $this->db->select( for ($year = $this->firstFYStart; $year <= $this->currentFYStart; $year++) {
"SELECT id, member_id, financial_year, person_type FROM subscriptions $fy = $year . '/' . ($year + 1);
WHERE base_amount = '0.00' AND status != 'exempt'" $discountPct = $this->yearDiscounts[$fy] ?? null;
);
foreach ($zeroRows as $row) {
$rate = $this->getRateForYear($row['financial_year'], $row['person_type']);
if (bccomp($rate, '0', 2) > 0 && !$dryRun) {
$this->db->update('subscriptions', [
'base_amount' => $rate,
'total_amount' => $rate,
'updated_at' => $this->ts,
], 'id = ?', [(int)$row['id']]);
$this->stats['amount_corrections']++;
} elseif (bccomp($rate, '0', 2) > 0) {
$this->stats['amount_corrections']++;
}
}
// Fix subscriptions with wrong rates for their year (check against expected)
foreach (['2023/2024', '2024/2025'] as $fy) {
$expectedRates = $this->rates[$fy];
foreach (['member', 'spouse', 'child', 'temporary'] as $type) { foreach (['member', 'spouse', 'child', 'temporary'] as $type) {
$baseRate = $this->getRateForYear($fy, $type);
$discount = $discountPct ? bcdiv(bcmul($baseRate, $discountPct, 4), '100', 2) : '0.00';
$totalAmount = bcsub($baseRate, $discount, 2);
// Find rows with wrong base, discount, or total (skip paid rows to preserve payment history)
$wrong = $this->db->select( $wrong = $this->db->select(
"SELECT id FROM subscriptions "SELECT id, base_amount, discount_amount, total_amount, status FROM subscriptions
WHERE financial_year = ? AND person_type = ? AND base_amount != ? AND status != 'exempt' AND status != 'paid'", WHERE financial_year = ? AND person_type = ? AND status != 'paid'
[$fy, $type, $expectedRates[$type]] AND (base_amount != ? OR discount_amount != ? OR total_amount != ?)",
[$fy, $type, $baseRate, $discount, $totalAmount]
); );
foreach ($wrong as $w) { foreach ($wrong as $w) {
if (!$dryRun) { if (!$dryRun) {
$this->db->update('subscriptions', [ $this->db->update('subscriptions', [
'base_amount' => $expectedRates[$type], 'base_amount' => $baseRate,
'total_amount' => $expectedRates[$type], 'discount_amount' => $discount,
'total_amount' => $totalAmount,
'updated_at' => $this->ts, 'updated_at' => $this->ts,
], 'id = ?', [(int)$w['id']]); ], 'id = ?', [(int)$w['id']]);
} }
if (bccomp($w['discount_amount'], $discount, 2) !== 0) {
$this->stats['discount_corrections']++;
} else {
$this->stats['amount_corrections']++; $this->stats['amount_corrections']++;
} }
} }
} }
} }
}
private function createMissingSubscriptions(bool $dryRun): void private function createMissingSubscriptions(bool $dryRun): void
{ {
...@@ -253,10 +231,10 @@ final class SubscriptionDataMigration ...@@ -253,10 +231,10 @@ final class SubscriptionDataMigration
foreach ($members as $member) { foreach ($members as $member) {
$memberId = (int)$member['id']; $memberId = (int)$member['id'];
$memberJoinFY = $this->dateToFY($member['created_at']); $memberJoinFY = max($this->dateToFY($member['created_at']), $this->firstFYStart);
$this->stats['members_processed']++; $this->stats['members_processed']++;
// Generate member subscription for each missing FY // Member subscription
$this->ensureSubscriptionsExist( $this->ensureSubscriptionsExist(
$memberId, 'member', $memberId, $member['full_name_ar'], $memberId, 'member', $memberId, $member['full_name_ar'],
$memberJoinFY, $dryRun $memberJoinFY, $dryRun
...@@ -269,9 +247,7 @@ final class SubscriptionDataMigration ...@@ -269,9 +247,7 @@ final class SubscriptionDataMigration
[$memberId] [$memberId]
); );
foreach ($spouses as $sp) { foreach ($spouses as $sp) {
$spJoinFY = $this->dateToFY($sp['effective_date']); $startFY = max($this->dateToFY($sp['effective_date']), $memberJoinFY);
// Spouse can't have subscription before the member joined
$startFY = max($spJoinFY, $memberJoinFY);
$this->ensureSubscriptionsExist( $this->ensureSubscriptionsExist(
$memberId, 'spouse', (int)$sp['id'], $sp['full_name_ar'], $memberId, 'spouse', (int)$sp['id'], $sp['full_name_ar'],
$startFY, $dryRun $startFY, $dryRun
...@@ -285,8 +261,7 @@ final class SubscriptionDataMigration ...@@ -285,8 +261,7 @@ final class SubscriptionDataMigration
[$memberId] [$memberId]
); );
foreach ($children as $ch) { foreach ($children as $ch) {
$chJoinFY = $this->dateToFY($ch['effective_date']); $startFY = max($this->dateToFY($ch['effective_date']), $memberJoinFY);
$startFY = max($chJoinFY, $memberJoinFY);
$this->ensureSubscriptionsExist( $this->ensureSubscriptionsExist(
$memberId, 'child', (int)$ch['id'], $ch['full_name_ar'], $memberId, 'child', (int)$ch['id'], $ch['full_name_ar'],
$startFY, $dryRun $startFY, $dryRun
...@@ -300,8 +275,7 @@ final class SubscriptionDataMigration ...@@ -300,8 +275,7 @@ final class SubscriptionDataMigration
[$memberId] [$memberId]
); );
foreach ($temps as $t) { foreach ($temps as $t) {
$tJoinFY = $this->dateToFY($t['effective_date']); $startFY = max($this->dateToFY($t['effective_date']), $memberJoinFY);
$startFY = max($tJoinFY, $memberJoinFY);
$this->ensureSubscriptionsExist( $this->ensureSubscriptionsExist(
$memberId, 'temporary', (int)$t['id'], $t['full_name_ar'], $memberId, 'temporary', (int)$t['id'], $t['full_name_ar'],
$startFY, $dryRun $startFY, $dryRun
...@@ -314,10 +288,12 @@ final class SubscriptionDataMigration ...@@ -314,10 +288,12 @@ final class SubscriptionDataMigration
int $memberId, string $personType, int $personId, string $personName, int $memberId, string $personType, int $personId, string $personName,
int $startFYYear, bool $dryRun int $startFYYear, bool $dryRun
): void { ): void {
// Never create before system start
$startFYYear = max($startFYYear, $this->firstFYStart);
for ($year = $startFYYear; $year <= $this->currentFYStart; $year++) { for ($year = $startFYYear; $year <= $this->currentFYStart; $year++) {
$fy = $year . '/' . ($year + 1); $fy = $year . '/' . ($year + 1);
// Check if subscription already exists
$existing = $this->db->selectOne( $existing = $this->db->selectOne(
"SELECT id FROM subscriptions "SELECT id FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND person_type = ? AND person_id = ?", WHERE member_id = ? AND financial_year = ? AND person_type = ? AND person_id = ?",
...@@ -326,10 +302,11 @@ final class SubscriptionDataMigration ...@@ -326,10 +302,11 @@ final class SubscriptionDataMigration
if ($existing) continue; if ($existing) continue;
$rate = $this->getRateForYear($fy, $personType); $baseRate = $this->getRateForYear($fy, $personType);
$discountPct = $this->yearDiscounts[$fy] ?? null;
$discount = $discountPct ? bcdiv(bcmul($baseRate, $discountPct, 4), '100', 2) : '0.00';
$totalAmount = bcsub($baseRate, $discount, 2);
$devFee = ($personType === 'member') ? $this->devFee : '0.00'; $devFee = ($personType === 'member') ? $this->devFee : '0.00';
// Determine status: current FY = pending, past FYs = overdue
$status = ($year === $this->currentFYStart) ? 'pending' : 'overdue'; $status = ($year === $this->currentFYStart) ? 'pending' : 'overdue';
if (!$dryRun) { if (!$dryRun) {
...@@ -339,10 +316,10 @@ final class SubscriptionDataMigration ...@@ -339,10 +316,10 @@ final class SubscriptionDataMigration
'person_type' => $personType, 'person_type' => $personType,
'person_id' => $personId, 'person_id' => $personId,
'person_name' => $personName, 'person_name' => $personName,
'base_amount' => $rate, 'base_amount' => $baseRate,
'development_fee' => $devFee, 'development_fee' => $devFee,
'discount_amount' => '0.00', 'discount_amount' => $discount,
'total_amount' => $rate, 'total_amount' => $totalAmount,
'paid_amount' => '0.00', 'paid_amount' => '0.00',
'fine_amount' => '0.00', 'fine_amount' => '0.00',
'status' => $status, 'status' => $status,
...@@ -355,9 +332,14 @@ final class SubscriptionDataMigration ...@@ -355,9 +332,14 @@ final class SubscriptionDataMigration
} }
} }
private function applyFines(): void private function resetAndApplyFines(): void
{ {
// Boot App for RuleEngine access // Reset all fines first (recalculate from scratch)
$this->db->query(
"UPDATE subscriptions SET fine_amount = '0.00', updated_at = ? WHERE status = 'overdue'",
[$this->ts]
);
$app = App::getInstance(); $app = App::getInstance();
if (!$app->db()) { if (!$app->db()) {
$app->setDb($this->db); $app->setDb($this->db);
...@@ -377,7 +359,6 @@ final class SubscriptionDataMigration ...@@ -377,7 +359,6 @@ final class SubscriptionDataMigration
$fineAmount = $detail['fine_amount'] ?? '0.00'; $fineAmount = $detail['fine_amount'] ?? '0.00';
if (bccomp($fineAmount, '0', 2) <= 0) continue; if (bccomp($fineAmount, '0', 2) <= 0) continue;
// Distribute fine proportionally across subscription rows for this FY
$fyRows = $this->db->select( $fyRows = $this->db->select(
"SELECT id, total_amount FROM subscriptions "SELECT id, total_amount FROM subscriptions
WHERE member_id = ? AND financial_year = ? AND status = 'overdue'", WHERE member_id = ? AND financial_year = ? AND status = 'overdue'",
...@@ -388,16 +369,13 @@ final class SubscriptionDataMigration ...@@ -388,16 +369,13 @@ final class SubscriptionDataMigration
foreach ($fyRows as $fr) { foreach ($fyRows as $fr) {
$fyTotal = bcadd($fyTotal, $fr['total_amount'], 2); $fyTotal = bcadd($fyTotal, $fr['total_amount'], 2);
} }
if (bccomp($fyTotal, '0', 2) <= 0) continue; if (bccomp($fyTotal, '0', 2) <= 0) continue;
foreach ($fyRows as $fr) { foreach ($fyRows as $fr) {
$proportion = bcdiv($fr['total_amount'], $fyTotal, 6); $proportion = bcdiv($fr['total_amount'], $fyTotal, 6);
$rowFine = bcmul($fineAmount, $proportion, 2); $rowFine = bcmul($fineAmount, $proportion, 2);
$this->db->update('subscriptions', [ $this->db->update('subscriptions', [
'fine_amount' => $rowFine, 'fine_amount' => $rowFine,
'status' => 'overdue',
'updated_at' => $this->ts, 'updated_at' => $this->ts,
], 'id = ?', [(int)$fr['id']]); ], 'id = ?', [(int)$fr['id']]);
} }
...@@ -429,81 +407,96 @@ final class SubscriptionDataMigration ...@@ -429,81 +407,96 @@ final class SubscriptionDataMigration
{ {
$issues = []; $issues = [];
// Check 1: Members without subscriptions for current FY // Check 1: No subscriptions before system start
$preSys = $this->db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions WHERE financial_year < ? AND status != 'paid'",
[$this->firstFYStart . '/' . ($this->firstFYStart + 1)]
);
if ((int)($preSys['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'pre_system_subscriptions', 'count' => (int)$preSys['cnt']];
}
// Check 2: 2023/2024 discount applied correctly
$noDiscount = $this->db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions
WHERE financial_year = '2023/2024' AND discount_amount = '0.00' AND status != 'paid' AND status != 'exempt'"
);
if ((int)($noDiscount['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'missing_2023_discount', 'count' => (int)$noDiscount['cnt']];
}
// Check 3: Members without subscriptions for current FY
$missing = $this->db->select( $missing = $this->db->select(
"SELECT m.id, m.membership_number, m.full_name_ar "SELECT m.id, m.membership_number
FROM members m FROM members m
WHERE m.status = 'active' AND m.is_archived = 0 WHERE m.status = 'active' AND m.is_archived = 0
AND m.membership_type NOT IN ('honorary', 'seasonal') AND m.membership_type NOT IN ('honorary', 'seasonal')
AND m.id NOT IN ( AND NOT EXISTS (
SELECT DISTINCT member_id FROM subscriptions WHERE financial_year = ? SELECT 1 FROM subscriptions s WHERE s.member_id = m.id AND s.financial_year = ?
)", )",
[$this->currentFY] [$this->currentFY]
); );
if (!empty($missing)) { if (!empty($missing)) {
$issues[] = ['type' => 'missing_current_fy', 'count' => count($missing), 'members' => $missing]; $issues[] = ['type' => 'missing_current_fy_members', 'count' => count($missing)];
} }
// Check 2: Active spouses without subscription in current FY // Check 4: Active spouses without subscription in current FY
$missingSpouses = $this->db->select( $missingSpouses = $this->db->selectOne(
"SELECT sp.id, sp.member_id, sp.full_name_ar "SELECT COUNT(*) as cnt FROM spouses sp
FROM spouses sp
JOIN members m ON m.id = sp.member_id AND m.status = 'active' AND m.is_archived = 0 JOIN members m ON m.id = sp.member_id AND m.status = 'active' AND m.is_archived = 0
AND m.membership_type NOT IN ('honorary','seasonal')
WHERE sp.is_archived = 0 AND sp.status = 'active' WHERE sp.is_archived = 0 AND sp.status = 'active'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM subscriptions s SELECT 1 FROM subscriptions s
WHERE s.member_id = sp.member_id AND s.financial_year = ? WHERE s.member_id = sp.member_id AND s.financial_year = '{$this->currentFY}'
AND s.person_type = 'spouse' AND s.person_id = sp.id AND s.person_type = 'spouse' AND s.person_id = sp.id
)", )"
[$this->currentFY]
); );
if (!empty($missingSpouses)) { if ((int)($missingSpouses['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'missing_spouse_subs', 'count' => count($missingSpouses), 'details' => $missingSpouses]; $issues[] = ['type' => 'missing_spouse_subs', 'count' => (int)$missingSpouses['cnt']];
} }
// Check 3: Active children without subscription in current FY // Check 5: Active children without subscription in current FY
$missingChildren = $this->db->select( $missingChildren = $this->db->selectOne(
"SELECT ch.id, ch.member_id, ch.full_name_ar "SELECT COUNT(*) as cnt FROM children ch
FROM children ch
JOIN members m ON m.id = ch.member_id AND m.status = 'active' AND m.is_archived = 0 JOIN members m ON m.id = ch.member_id AND m.status = 'active' AND m.is_archived = 0
AND m.membership_type NOT IN ('honorary','seasonal')
WHERE ch.is_archived = 0 AND ch.status = 'active' WHERE ch.is_archived = 0 AND ch.status = 'active'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM subscriptions s SELECT 1 FROM subscriptions s
WHERE s.member_id = ch.member_id AND s.financial_year = ? WHERE s.member_id = ch.member_id AND s.financial_year = '{$this->currentFY}'
AND s.person_type = 'child' AND s.person_id = ch.id AND s.person_type = 'child' AND s.person_id = ch.id
)", )"
[$this->currentFY]
); );
if (!empty($missingChildren)) { if ((int)($missingChildren['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'missing_child_subs', 'count' => count($missingChildren), 'details' => $missingChildren]; $issues[] = ['type' => 'missing_child_subs', 'count' => (int)$missingChildren['cnt']];
} }
// Check 4: Duplicates // Check 6: Duplicates
$dupes = $this->db->select( $dupes = $this->db->selectOne(
"SELECT member_id, financial_year, person_type, person_id, COUNT(*) as cnt "SELECT COUNT(*) as cnt FROM (
FROM subscriptions SELECT member_id, financial_year, person_type, person_id
GROUP BY member_id, financial_year, person_type, person_id FROM subscriptions GROUP BY member_id, financial_year, person_type, person_id HAVING COUNT(*) > 1
HAVING cnt > 1" ) d"
); );
if (!empty($dupes)) { if ((int)($dupes['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'duplicates', 'count' => count($dupes), 'details' => $dupes]; $issues[] = ['type' => 'duplicates', 'count' => (int)$dupes['cnt']];
} }
// Check 5: Orphaned person_ids (check ALL records including archived) // Check 7: Orphaned person_ids
$orphans = $this->db->select( $orphans = $this->db->selectOne(
"SELECT s.id, s.member_id, s.person_type, s.person_id, s.financial_year "SELECT COUNT(*) as cnt FROM subscriptions s WHERE
FROM subscriptions s (s.person_type = 'spouse' AND NOT EXISTS (SELECT 1 FROM spouses WHERE id = s.person_id AND member_id = s.member_id))
WHERE (s.person_type = 'spouse' AND NOT EXISTS (SELECT 1 FROM spouses WHERE id = s.person_id AND member_id = s.member_id))
OR (s.person_type = 'child' AND NOT EXISTS (SELECT 1 FROM children WHERE id = s.person_id AND member_id = s.member_id)) OR (s.person_type = 'child' AND NOT EXISTS (SELECT 1 FROM children WHERE id = s.person_id AND member_id = s.member_id))
OR (s.person_type = 'temporary' AND NOT EXISTS (SELECT 1 FROM temporary_members WHERE id = s.person_id AND member_id = s.member_id))" OR (s.person_type = 'temporary' AND NOT EXISTS (SELECT 1 FROM temporary_members WHERE id = s.person_id AND member_id = s.member_id))"
); );
if (!empty($orphans)) { if ((int)($orphans['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'orphaned_person_ids', 'count' => count($orphans), 'details' => $orphans]; $issues[] = ['type' => 'orphaned_person_ids', 'count' => (int)$orphans['cnt']];
} }
// Check 6: Zero-amount non-exempt subscriptions // Check 8: Zero-amount non-exempt subscriptions
$zeros = $this->db->selectOne( $zeros = $this->db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions WHERE base_amount = '0.00' AND status != 'exempt'" "SELECT COUNT(*) as cnt FROM subscriptions WHERE base_amount = '0.00' AND status NOT IN ('exempt','paid')"
); );
if ((int)($zeros['cnt'] ?? 0) > 0) { if ((int)($zeros['cnt'] ?? 0) > 0) {
$issues[] = ['type' => 'zero_amounts', 'count' => (int)$zeros['cnt']]; $issues[] = ['type' => 'zero_amounts', 'count' => (int)$zeros['cnt']];
......
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