# Carnets Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Carnets module manages **membership ID cards (carnets)** and **guest entry invitations**. It handles:
- Issuing carnets for members and their dependents (spouses, children, temporary members)
- Carnet replacement (lost/damaged) with fee via Cashier
- Carnet deactivation with reason tracking
- QR code generation for carnet identification
- Print tracking (count, who printed, when)
- Guest entry management (invitation book system)
- Invitation quota tracking per carnet (total/used)
- Guest check-in/check-out and cancellation

It does **NOT** directly manage:
- Member eligibility validation (delegates to internal checks against Members, Fines, Subscriptions, Installments)
- Payment collection (delegates replacement fee to Cashier/PaymentRequestService)
- Notifications (Notifications module listens to carnet events)
- Accounting entries for guest entries (Accounting module listens to events)

---

## 2. Directory & File Structure

```
app/Modules/Carnets/
├── bootstrap.php                          # Menu + permission registration
├── Routes.php                             # Web routes (14 routes)
├── Controllers/
│   ├── CarnetController.php              # Main controller (index, issue, print, deactivate, replace)
│   └── GuestEntryController.php          # Guest entries (CRUD, checkout, cancel, member summary)
├── Models/
│   ├── Carnet.php                        # Main model (search, generateNumber, getActive)
│   └── CarnetGuestEntry.php             # Guest entry model (search, activity types, counts)
├── Services/
│   ├── CarnetPrintService.php           # Eligibility checks (member + dependent)
│   ├── GuestEntryService.php            # Entry recording, checkout, cancellation, quota mgmt
│   └── QRCodeGenerator.php              # SVG QR code generation (custom, no external lib)
└── Views/
    ├── index.php                         # Carnet list with search/filter
    ├── print.php                         # Carnet print layout (member)
    ├── print-dependent.php              # Carnet print layout (dependent)
    ├── guest_entries.php                # Guest entry log with filters
    └── guest_entry_create.php           # Record new guest entry form
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `carnets` Table (3 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| member_id | bigint unsigned | NO | MUL | FK to members |
| dependent_type | varchar(30) | YES | MUL | 'spouses', 'children', 'temporary_members' or NULL |
| dependent_id | bigint unsigned | YES | | FK to the dependent table |
| carnet_number | varchar(50) | NO | UNI | Format: CRN-YYYY-NNNNNN |
| carnet_type | varchar(30) | NO | MUL | 'regular' or 'seasonal' |
| card_variant | varchar(20) | NO | | 'main' or 'dependent' |
| total_invitations | int unsigned | NO | | Default: 10 |
| used_invitations | int unsigned | NO | | Default: 0 |
| qr_code_data | varchar(500) | YES | | Encoded QR string |
| is_active | tinyint(1) | NO | MUL | Default: 1 |
| issued_at | timestamp | NO | | Auto-set on creation |
| issued_by | bigint unsigned | YES | | FK to employees |
| deactivated_at | timestamp | YES | | When deactivated |
| deactivated_reason | varchar(200) | YES | | Reason for deactivation |
| replacement_count | int unsigned | NO | | Default: 0, increments on each replacement |
| previous_carnet_id | bigint unsigned | YES | MUL | Self-FK to previous carnet |
| print_count | int unsigned | NO | | Default: 0 |
| last_printed_at | timestamp | YES | | Last time printed |
| last_printed_by | bigint unsigned | YES | | FK to employees |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |

**Current data distribution:**
- Total: 3 carnets
- Active: 2
- All are type 'regular', variant 'main'

### 3.2 `carnet_guest_entries` Table (0 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| carnet_id | bigint unsigned | NO | MUL | FK to carnets |
| member_id | bigint unsigned | NO | MUL | FK to members (denormalized) |
| guest_name | varchar(200) | NO | | Guest's name |
| guest_phone | varchar(30) | YES | | Guest's phone |
| guest_national_id | varchar(20) | YES | | Guest's NID |
| guest_count | int unsigned | NO | | Default: 1 (multiple guests per entry) |
| entry_date | date | NO | MUL | Date of visit |
| entry_time | time | NO | | Check-in time |
| exit_time | time | YES | | Check-out time |
| facility_id | bigint unsigned | YES | MUL | FK to facilities |
| activity_type | varchar(50) | NO | | free_swim, court_booking, recreation, etc. |
| reservation_id | bigint unsigned | YES | | FK to reservations (optional link) |
| pool_booking_id | bigint unsigned | YES | | FK to pool bookings (optional link) |
| amount_paid | decimal(10,2) | NO | | Default: 0.00 |
| payment_id | bigint unsigned | YES | | FK to payments (optional) |
| status | varchar(20) | NO | MUL | active, checked_out, cancelled |
| notes | text | YES | | |
| recorded_by | bigint unsigned | YES | | FK to employees |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |

---

## 4. Routes (14 total)

| Method | Path | Handler | Permission |
|--------|------|---------|------------|
| GET | /carnets | CarnetController@index | carnet.view |
| GET | /carnets/issue/{memberId} | CarnetController@issue | carnet.issue |
| POST | /carnets/issue/{memberId} | CarnetController@issue | carnet.issue |
| GET | /carnets/{id}/print | CarnetController@print | carnet.print |
| POST | /carnets/{id}/deactivate | CarnetController@deactivate | carnet.deactivate |
| GET | /carnets/replace/{memberId} | CarnetController@replace | carnet.issue |
| POST | /carnets/issue/{memberId}/{type}/{dependentId} | CarnetController@issueDependent | carnet.issue |
| GET | /carnets/{id}/print-dependent | CarnetController@printDependent | carnet.print |
| GET | /carnets/guest-entries | GuestEntryController@index | carnet.view_guests |
| GET | /carnets/guest-entries/create | GuestEntryController@create | carnet.record_guest |
| POST | /carnets/guest-entries | GuestEntryController@store | carnet.record_guest |
| POST | /carnets/guest-entries/{id}/checkout | GuestEntryController@checkout | carnet.record_guest |
| POST | /carnets/guest-entries/{id}/cancel | GuestEntryController@cancel | carnet.record_guest |
| GET | /api/carnets/member/{memberId}/summary | GuestEntryController@memberSummary | carnet.view_guests |

---

## 5. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `carnet.issued` | `{carnet_id, member_id, carnet_number, issued_by, ?dependent_type, ?dependent_id}` | CarnetController::issue, CarnetController::issueDependent |
| `carnet.printed` | `{carnet_id, member_id, printed_by, print_count}` | CarnetController::print |
| `carnet.deactivated` | `{carnet_id, member_id, reason}` | CarnetController::deactivate |
| `carnet_guest.entry_recorded` | `{entry_id, carnet_id, member_id, guest_name, facility_id, activity, amount}` | GuestEntryService::recordEntry |
| `carnet.low_balance` | `{carnet_id, member_id, remaining}` | GuestEntryService::recordEntry (when remaining <= 2) |

---

## 6. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `carnet.low_balance` | Notifications | Triggers notification to member about low invitation balance |
| `carnet_guest.entry_recorded` | Notifications | Triggers notification processing |
| `carnet.guest_entry_recorded` | Accounting | Auto-posts journal entry for guest entry revenue |

**Note:** There is a naming mismatch — the event dispatched is `carnet_guest.entry_recorded` but Accounting listens for `carnet.guest_entry_recorded`. This may be a bug preventing accounting integration.

---

## 7. Cross-Module Dependencies

### 7.1 Carnets module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Pricing | PricingEngine::getServiceFee('SVC_CARNET_REPLACEMENT') |
| Rules | RuleEngine::getValue('CARNET_REPLACEMENT_FEE', 'amount') |
| Cashier | PaymentRequestService::createRequest (replacement fee) |
| Fines | Fine::isSuspended (eligibility check) |
| Members | Members table (status, photo_path, subscription checks) |

### 7.2 Other modules that IMPORT FROM Carnets:

| Module | What it imports |
|--------|----------------|
| SportsActivity | QRCodeGenerator (for SA cards), CarnetGuestEntry model, GuestEntryService (service desk guest entry) |
| Members | Sidebar menu links to /carnets, carnet.issue permission check in member profile |
| Accounting | Listens for carnet guest entry events |
| Notifications | Listens for carnet.low_balance and carnet_guest.entry_recorded events |
| Tutorials | Tutorial content about carnet workflows |

---

## 8. Permissions

| Key | Description |
|-----|-------------|
| carnet.view | View carnet list |
| carnet.issue | Issue new carnets (member and dependent) |
| carnet.print | Print a carnet |
| carnet.deactivate | Deactivate an active carnet |
| carnet.replace | Initiate carnet replacement (uses carnet.issue route) |
| carnet.view_log | View carnet history log |
| carnet.view_guests | View guest entry records |
| carnet.record_guest | Record, checkout, and cancel guest entries |

---

## 9. Core Business Flows

### 9.1 Issue Carnet (Member)

```
1. Check eligibility (CarnetPrintService::checkEligibility):
   - Member must be 'active' or 'payment_pending'
   - Current FY subscription must be paid
   - No active suspension/ban (Fines)
   - No overdue installments
   - No unpaid fines
   - Profile photo must be uploaded
2. Deactivate any existing active carnet (auto-replaced)
3. Generate carnet number (CRN-YYYY-NNNNNN sequential)
4. Generate QR code data (THECLUB:{membership_number}:{hash})
5. Create carnet record
6. Dispatch carnet.issued event
7. Redirect to print page
```

### 9.2 Issue Carnet (Dependent)

```
1. Check member eligibility (same as above)
2. Check dependent eligibility:
   - Dependent must exist and belong to member
   - Dependent must have photo uploaded
   - Dependent status must be 'active' or 'pending_payment'
3. Deactivate existing active dependent carnet
4. Generate number and QR (includes dependent suffix)
5. Create with card_variant='dependent', dependent_type and dependent_id set
```

### 9.3 Replacement Flow

```
1. Look up SVC_CARNET_REPLACEMENT fee (default 200 EGP)
2. Create PaymentRequest via Cashier
3. Member pays through normal cashier flow
4. After payment confirmed, issue new carnet (replacement_count incremented)
```

### 9.4 Guest Entry Flow

```
1. Select member + active carnet
2. Validate carnet eligibility (active, member active, invitations remaining)
3. Record entry (guest name, phone, NID, facility, activity type)
4. Increment used_invitations on carnet
5. Dispatch carnet_guest.entry_recorded
6. If remaining <= 2, dispatch carnet.low_balance
7. Guest can later be checked-out (exit_time set) or cancelled (invitations refunded)
```

---

## 10. High-Risk Areas

### 10.1 Carnet Number Sequencing
- Uses MAX() query to find last number — potential race condition under concurrent issuance
- Format: CRN-YYYY-NNNNNN (resets each year)
- If broken: duplicate carnet numbers issued

### 10.2 Invitation Quota Management
- `used_invitations` incremented/decremented directly via SQL UPDATE
- Cancellation refunds the guest_count back
- No locking mechanism — concurrent entries could exceed total_invitations
- No audit trail for quota changes beyond the guest entry records

### 10.3 Eligibility Check Dependencies
- CarnetPrintService queries 5 different tables (members, subscriptions, fines, installments, carnets)
- Uses `$db->tableExists()` which adds overhead and can mask missing tables
- If subscription/fines tables are corrupted or schema changes, eligibility silently passes

### 10.4 Event Name Mismatch
- GuestEntryService dispatches `carnet_guest.entry_recorded`
- Accounting bootstrap listens for `carnet.guest_entry_recorded`
- These are DIFFERENT event names — accounting journal entries for guest entry revenue may never be posted

### 10.5 QR Code Implementation
- Custom SVG-based QR generator (not a real QR standard)
- Produces a deterministic visual pattern from md5 hash
- Scanner apps cannot read these — they are visual placeholders only
- Data format: `THECLUB:{membership_number}:{8-char-hash}`

---

## 11. Configuration Dependencies

| Config Key | Purpose |
|-----------|---------|
| Service Catalog: `SVC_CARNET_REPLACEMENT` | Replacement fee amount |
| Rule Engine: `CARNET_REPLACEMENT_FEE` | Fallback fee (200 EGP default) |
| carnets.total_invitations (DB column default) | Guest invitations per carnet (default: 10) |

---

## 12. Known Patterns & Gotchas

1. **Auto-deactivation on reissue**: Issuing a new carnet automatically deactivates any active one for the same member/dependent
2. **Dependent carnets use table name as type**: `dependent_type` stores raw table name ('spouses', 'children', 'temporary_members') — used directly in SQL queries
3. **Print count tracking**: Every visit to the print page increments the counter and records who printed — used for audit
4. **SportsActivity shares QR code generator**: SA cards use the same QRCodeGenerator service from this module
5. **Service desk integration**: SportsActivity's service desk directly uses GuestEntryService to record guest entries
6. **Financial year calculation**: Uses July-June fiscal year (same as Members module) for subscription checks
7. **No soft delete**: Carnets table has no `is_archived` — deactivation sets `is_active=0` instead
