Commit 58084696 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(subscriptions): auto-sync FY subscription row when dependent is activated

SubscriptionSyncService::syncForDependent() inserts a pending subscription
row for the current financial year whenever a spouse, child, or temporary
member becomes active. Triggered via two paths in Subscriptions bootstrap:

- Cashier path: spouse.fee_paid / child.fee_paid / temporary.fee_paid
- Zero-fee path: *.added events where fee = 0 (immediate activation)

Guards: member must be active + non-exempt type; dedup prevents duplicates.
Rates resolved identically to SubscriptionGenerator (year-specific catalog
code → generic code → hard fallback). Never throws — safe in event context.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 8b7ccbd6
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Rules\Services\RuleEngine;
/**
* Adds a subscription row for a newly-activated dependent (spouse / child / temporary)
* for the current financial year.
*
* Called from Subscriptions bootstrap on spouse.fee_paid, child.fee_paid,
* temporary.fee_paid, and on *.added when the dependent is immediately active (fee = 0).
*/
final class SubscriptionSyncService
{
/**
* @param string $personType 'spouse' | 'child' | 'temporary'
* @param int $personId PK in the respective table
* @param int $memberId FK to members
*/
public static function syncForDependent(string $personType, int $personId, int $memberId): void
{
try {
$db = App::getInstance()->db();
// Only sync for active members (not seasonal/honorary types)
$member = $db->selectOne(
"SELECT id, membership_type, status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if (!$member || $member['status'] !== 'active') {
return;
}
$exemptTypes = self::getExemptTypes();
if (in_array($member['membership_type'], $exemptTypes, true)) {
return;
}
$fy = financial_year();
// Dedup guard — matches SubscriptionGenerator logic exactly
$existing = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = ? AND person_id = ?",
[$memberId, $fy, $personType, $personId]
);
if ($existing) {
return;
}
// Resolve the dependent's name from their table
$tableMap = [
'spouse' => 'spouses',
'child' => 'children',
'temporary' => 'temporary_members',
];
$table = $tableMap[$personType] ?? null;
if (!$table) {
return;
}
$person = $db->selectOne(
"SELECT full_name_ar FROM {$table} WHERE id = ? AND is_archived = 0",
[$personId]
);
if (!$person) {
return;
}
// Resolve rate using the same logic as SubscriptionGenerator
$yearSuffix = explode('/', $fy)[0] ?? '';
$rateMap = [
'spouse' => ['SVC_ANNUAL_SPOUSE_' . $yearSuffix, 'SVC_ANNUAL_SPOUSE', '492.00'],
'child' => ['SVC_ANNUAL_CHILD_' . $yearSuffix, 'SVC_ANNUAL_CHILD', '222.00'],
'temporary' => ['SVC_ANNUAL_TEMP_' . $yearSuffix, 'SVC_ANNUAL_TEMP', '222.00'],
];
[$specific, $generic, $hardFallback] = $rateMap[$personType];
$rate = self::getRate($specific) ?: self::getRate($generic) ?: $hardFallback;
// Year-specific discount
$yearAdjustment = RuleEngine::get('SUBSCRIPTION_YEAR_ADJUSTMENT_' . $yearSuffix);
$discountPct = $yearAdjustment['discount_percentage'] ?? null;
$discount = $discountPct ? bcdiv(bcmul($rate, $discountPct, 4), '100', 2) : '0.00';
$total = bcsub($rate, $discount, 2);
$employee = App::getInstance()->currentEmployee();
$empId = $employee ? (int) $employee->id : null;
$ts = date('Y-m-d H:i:s');
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $fy,
'person_type' => $personType,
'person_id' => $personId,
'person_name' => $person['full_name_ar'],
'base_amount' => $rate,
'development_fee' => '0.00',
'discount_amount' => $discount,
'total_amount' => $total,
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $empId,
]);
Logger::info("SubscriptionSyncService: added {$personType} #{$personId} to FY {$fy} for member #{$memberId}");
} catch (\Throwable $e) {
Logger::error("SubscriptionSyncService::syncForDependent failed: " . $e->getMessage(), [
'person_type' => $personType,
'person_id' => $personId,
'member_id' => $memberId,
]);
}
}
private static function getExemptTypes(): array
{
try {
$db = App::getInstance()->db();
$fy = financial_year();
$rows = $db->select(
"SELECT membership_type FROM subscription_rate_overrides WHERE financial_year = ? AND is_exempt = 1",
[$fy]
);
$types = array_column($rows, 'membership_type');
return empty($types) ? ['honorary', 'seasonal'] : $types;
} catch (\Throwable $e) {
return ['honorary', 'seasonal'];
}
}
private static function getRate(string $serviceCode): string
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$row = $db->selectOne(
"SELECT base_amount FROM service_catalog WHERE service_code = ? AND is_active = 1 AND branch_id IS NULL AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?) ORDER BY effective_from DESC LIMIT 1",
[$serviceCode, $today, $today]
);
if (!$row || !$row['base_amount'] || bccomp($row['base_amount'], '0', 2) <= 0) {
return '';
}
return $row['base_amount'];
}
}
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Core\Registries\PermissionRegistry; use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
use App\Modules\Subscriptions\Services\SubscriptionSyncService;
// Menu registered centrally via Members/bootstrap.php under "membership" parent. // Menu registered centrally via Members/bootstrap.php under "membership" parent.
...@@ -11,3 +13,62 @@ PermissionRegistry::register('subscriptions', [ ...@@ -11,3 +13,62 @@ PermissionRegistry::register('subscriptions', [
'subscription.exempt' => ['ar' => 'إعفاء من اشتراك', 'en' => 'Exempt Subscription'], 'subscription.exempt' => ['ar' => 'إعفاء من اشتراك', 'en' => 'Exempt Subscription'],
'subscription.generate_batch' => ['ar' => 'توليد اشتراكات', 'en' => 'Generate Batch'], 'subscription.generate_batch' => ['ar' => 'توليد اشتراكات', 'en' => 'Generate Batch'],
]); ]);
// ─── Auto-sync subscriptions when a dependent is activated ───────────────────
// Path A: fee already paid through Cashier → dependent just became active
EventBus::listen('spouse.fee_paid', function (array $data) {
$spouseId = (int) ($data['spouse_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($spouseId && $memberId) {
SubscriptionSyncService::syncForDependent('spouse', $spouseId, $memberId);
}
});
EventBus::listen('child.fee_paid', function (array $data) {
$childId = (int) ($data['child_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($childId && $memberId) {
SubscriptionSyncService::syncForDependent('child', $childId, $memberId);
}
});
EventBus::listen('temporary.fee_paid', function (array $data) {
$tempId = (int) ($data['temp_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($tempId && $memberId) {
SubscriptionSyncService::syncForDependent('temporary', $tempId, $memberId);
}
});
// Path B: fee = 0 → dependent was set active immediately in the controller store()
// Guard: only act when fee is zero (status already 'active' at dispatch time).
EventBus::listen('spouse.added', function (array $data) {
if (bccomp((string) ($data['fee'] ?? '0'), '0', 2) === 0) {
$spouseId = (int) ($data['spouse_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($spouseId && $memberId) {
SubscriptionSyncService::syncForDependent('spouse', $spouseId, $memberId);
}
}
});
EventBus::listen('child.added', function (array $data) {
if (bccomp((string) ($data['fee'] ?? '0'), '0', 2) === 0) {
$childId = (int) ($data['child_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($childId && $memberId) {
SubscriptionSyncService::syncForDependent('child', $childId, $memberId);
}
}
});
EventBus::listen('temporary.added', function (array $data) {
if (bccomp((string) ($data['fee'] ?? '0'), '0', 2) === 0) {
$tempId = (int) ($data['temporary_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($tempId && $memberId) {
SubscriptionSyncService::syncForDependent('temporary', $tempId, $memberId);
}
}
});
\ No newline at end of file
# Subscriptions Module — Architecture Map # Subscriptions Module — Architecture Map
> **Last updated:** 2026-07-04 (first FY = 2023/2024 enforced, 50% discount applied, full data migration) > **Last updated:** 2026-07-18 (added SubscriptionSyncService — real-time dependent sync on activation events)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -29,7 +29,7 @@ It does **NOT** directly manage: ...@@ -29,7 +29,7 @@ It does **NOT** directly manage:
``` ```
app/Modules/Subscriptions/ app/Modules/Subscriptions/
├── bootstrap.php # Permission registration only (menu via Members) ├── bootstrap.php # Permission registration + dependent activation listeners
├── Routes.php # 6 web routes ├── Routes.php # 6 web routes
├── Controllers/ ├── Controllers/
│ └── SubscriptionController.php # All CRUD + batch operations │ └── SubscriptionController.php # All CRUD + batch operations
...@@ -38,7 +38,8 @@ app/Modules/Subscriptions/ ...@@ -38,7 +38,8 @@ app/Modules/Subscriptions/
├── Services/ ├── Services/
│ ├── SubscriptionGenerator.php # Batch generation for a financial year │ ├── SubscriptionGenerator.php # Batch generation for a financial year
│ ├── SubscriptionCalculator.php # Late fine calculation + reinstatement check │ ├── SubscriptionCalculator.php # Late fine calculation + reinstatement check
│ └── OverdueFineApplicator.php # Cron: apply fines + on-demand per-member fine calc │ ├── OverdueFineApplicator.php # Cron: apply fines + on-demand per-member fine calc
│ └── SubscriptionSyncService.php # Real-time: adds FY row when dependent becomes active
└── Views/ └── Views/
├── index.php # Paginated list with filters (year, status, person_type) ├── index.php # Paginated list with filters (year, status, person_type)
├── batch-generate.php # Admin form to trigger batch generation ├── batch-generate.php # Admin form to trigger batch generation
...@@ -134,6 +135,35 @@ service codes. All 204 initially generated subscriptions had `base_amount=0.00`. ...@@ -134,6 +135,35 @@ 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 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. 3. All 204 bad rows manually corrected via UPDATE on production DB.
### 5.1b Real-Time Dependent Sync (SubscriptionSyncService) — Added 2026-07-18
When a spouse, child, or temporary member is activated (either immediately because fee=0, or
after the Cashier processes their addition fee), a subscription row for the **current financial
year** is automatically inserted for them.
```
Trigger A — fee paid path (Cashier dispatches):
spouse.fee_paid → SubscriptionSyncService::syncForDependent('spouse', $spouseId, $memberId)
child.fee_paid → SubscriptionSyncService::syncForDependent('child', $childId, $memberId)
temporary.fee_paid→ SubscriptionSyncService::syncForDependent('temporary', $tempId, $memberId)
Trigger B — zero-fee path (controller dispatches *.added with fee=0):
spouse.added (fee=0) → syncForDependent('spouse', ...)
child.added (fee=0) → syncForDependent('child', ...)
temporary.added(fee=0) → syncForDependent('temporary', ...)
syncForDependent logic:
1. Guard: member must be active + not an exempt membership type (honorary/seasonal)
2. Dedup: skip if subscriptions row already exists (member_id + FY + person_type + person_id)
3. Resolve person name from the dependent's table
4. Resolve rate via service_catalog (year-specific code → generic code → hard fallback)
5. Apply year-specific discount if SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule exists
6. INSERT pending subscription row
7. Log success or catch + log error (never throws — safe in event handler context)
```
**Idempotent:** safe to re-fire; the dedup guard prevents double rows.
### 5.2 Subscription Payment (with FIFO enforcement) ### 5.2 Subscription Payment (with FIFO enforcement)
``` ```
......
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