Commit 05a6e6f8 authored by DevPilot's avatar DevPilot

docs: extreme-detail reference for all 5 system wizards

Member retroactive entry, SA player registration, SA hourly booking,
swimming pool reservations, and the accounting revenue-mapping wizard.
Every field, validation rule, permission key, button, DB write, and
error message, sourced from reading the actual controller/service code
rather than the UI — plus a list of real defects found along the way
(transaction leaks, dead endpoints, schema mismatches, silent failures)
flagged for a deliberate fix pass rather than folded in unannounced.
parent 9236b6a1
# Wizard: إدخال عضوية بأثر رجعي (Member Retroactive Entry)
`/members/retroactive-wizard` · `Members\Controllers\RetroactiveWizardController` · permission `member.retroactive`
## Purpose
Not a normal member-creation form. This is a **historical bulk-entry tool**: it creates a fully-formed legacy member — member + spouses/children/temporary dependents + all historical payments + an installment plan + years of backdated annual subscriptions + violations/fines — in a single transaction, with every date, receipt number, and journal entry stamped as if it happened in the past. It exists to migrate pre-system members into the ERP with their full financial history, and doubles as a scenario-testing tool (it ships with one-click date/value presets for common legacy scenarios).
The view's own banner is explicit about the danger: *"هذه الأداة تتجاوز جميع مسارات العمل المعتادة"* — "this tool bypasses all normal workflow paths." A normal member goes through New Member → Cashier payment → Subscription generator → Installments module → Violations module, each gated by its own checks. This wizard skips all of them and writes the end state directly.
**Who can use it:** despite the route comment ("super admin only") and the view's banner ("للمدير فقط"), enforcement today is the permission key `member.retroactive`, explicitly granted to `membership_director` and `it_admin` roles (`database/seeds/Phase_105_001_fix_role_access_gaps.php:70-86`), not restricted to super-admin identity. The seed's own comment records that this permission *replaced* an earlier hardcoded super-admin check.
## Routes
| Method | Path | Handler |
|---|---|---|
| GET | `/members/retroactive-wizard` | `RetroactiveWizardController@index` |
| POST | `/members/retroactive-wizard` | `RetroactiveWizardController@store` |
| POST | `/members/retroactive-wizard/preview` | `RetroactiveWizardController@preview` |
`index()` loads `branches` (active) and `qualifications` (active, ordered) for the two dropdowns. `preview()` is wired server-side to return a JSON summary but **nothing in the UI calls it** — the "final summary" shown at step 7 is computed entirely client-side in JS. Treat `/preview` as dead code.
`store()` reads `$_POST` directly (not the `Request` object) via the private `collectWizardData()`, hands the structured array to `RetroactiveMembershipService::createRetroactiveMember()`, and on failure flashes `_wizard_error` + `_old_input` and redirects back to the GET page — **but the view never reads `_old_input` back into the form fields**, so a validation failure wipes the entire 7-step form and the user must start over. The data is preserved server-side in the flash session but nothing renders it.
## The seven steps
### Step 1 — البيانات الأساسية (Basic Info)
| Field | Type | Required | Notes |
|---|---|---|---|
| `full_name_ar` | text | **yes** | |
| `full_name_en` | text | no | |
| `membership_number` | text | no | Blank → auto-assigned by `MemberNumberGenerator::assign()`. Filled → used as-is, which is how you preserve an old paper membership number. |
| `national_id` | text (14) | conditional | If 14 digits, parsed by `NationalIdParser` for DOB/gender. That parser's check-digit algorithm is documented in its own file as "undocumented — NOT validated." |
| `passport_number` | text | no | For foreign members |
| `date_of_birth` | date | conditional | Required unless a valid 14-digit NID is supplied |
| `gender` | select | default `male` | |
| `phone_mobile` | text | **yes** | |
| `phone_home`, `email` | text/email | no | |
| `branch_id` | select | **yes** | source: `branches WHERE is_active=1` |
| `membership_type` | select | default `working` | working / seasonal / sports / honorary / foreign |
| `nationality` | select | default `egyptian` | egyptian / foreign |
| `qualification_id` | select | no | source: `qualifications WHERE is_active=1 ORDER BY sort_order` |
| `occupation`, `residence_address`, `area`, `governorate` | text | no | |
### Step 2 — التواريخ (Dates)
`form_date` and `join_date`, both **required** dates. Five one-click presets set both to matching years (2018/2020/2022/2024/2015 → 7/5/3/1/10 years ago) — cosmetic only, no server effect. A client-side "timeline preview" estimates years-since-join, expected subscription count, and max installment months; it is not submitted.
### Step 3 — المالية (Financial)
**Form fee:** `form_fee_amount` (default 505), `form_fee_method` (cash/check/visa/bank_transfer, default cash), and check-specific fields (`form_fee_check_number/_bank/_date`) shown only when method = check.
**Membership value:** `membership_value` (default 150,000, with one-click presets at 150k/225k/300k/500k matching the qualification tiers), `discount_amount` (default 0), `membership_payment_date` (blank → falls back server-side to `join_date`), `membership_payment_method` (cash/check/visa/bank_transfer/installment), plus method-specific reference fields.
### Step 4 — الأقساط (Installments) — only if payment method = installment
`inst_down_payment` (default 25% of membership value), `inst_months` (1–30, default 12), `inst_interest_rate` (default 22%), `inst_start_date` (blank → `join_date`), down-payment method fields, then one row per month generated dynamically by JS with paid-date/method per installment, plus a repeatable cheque block (`cheque_number_{i}`, `_bank_{i}`, `_date_{i}`, `_amount_{i}`).
Interest is flat simple interest: `remaining × (rate/100) × (months/12)`. The client JS preview and the server (`RetroactiveMembershipService::createRetroactiveInstallmentPlan()`, bcmath) compute this independently with the same formula — they can diverge by per-installment rounding, but not in method.
**No client-side validation runs on this step at submit time** (see Validation below).
### Step 5 — الملحقون (Dependents)
Three independent repeaters — spouses, children, temporary members — each row added by a JS button and indexed `{i}`.
- **Spouse:** name*, national ID, DOB* (waived if NID given), nationality (مصري/أجنبي), marriage date*, phone, join date (defaults to member's), fee (auto-calculated: 1st spouse 15%, 2nd 10%+150/yr, 3rd 20%+200/yr, 4th 30%+300/yr, foreign flat 15% — hardcoded in the view JS), "fee charged separately" checkbox, payment method.
- **Child:** name*, national ID, DOB* (waived if NID), gender, join date, fee, separate-fee checkbox, payment method.
- **Temporary member:** name*, national ID, DOB* (waived if NID), gender, category* (parent / special_needs / unmarried_daughter / sister / stepchild / orphan / disabled_sibling / nanny), relationship, join date, fee, separate-fee checkbox, payment method.
(* = enforced both client- and server-side.)
### Step 6 — الاشتراكات (Subscriptions)
"توليد تلقائي من تاريخ الانضمام" walks every year from `join_date`'s year to the current year and builds one card per year with a row per still-active family member, pricing them from a **hardcoded JS rate table** (2023–2024: member/spouse 410, child/temp 185, +35 dev fee for the member only; 2025+: 492/222) with a matching discount table. "+ إضافة سنة يدوياً" adds a year manually. Status per year/person is editable (paid/pending/overdue/exempted).
**Important divergence:** the server does **not** use this JS rate table. `RetroactiveMembershipService` prices each year via `RuleEngine::get('SUBSCRIPTION_YEAR_ADJUSTMENT_{year}')` — a separate, database-driven rules engine. The JS numbers are a preview only; the actual amount charged is whatever the rule engine returns for that year. If the two disagree, trust the rule engine, not the on-screen preview.
Also note: the hidden `sub_paid_{n}` and `sub_fine_{n}` inputs are hardcoded to `0` regardless of the status you pick in the UI — this is harmless (the server recomputes `paid_amount` itself from the status) but will look wrong if you're reading the raw POST payload while debugging.
Duplicate protection: the server skips creating a subscription row if one already exists for the same member+year+person — safe to re-submit.
### Step 7 — المخالفات (Violations & Fines)
Repeatable rows: violation date, description (marked required with a `*` in the label but **not actually enforced**, client or server), evidence, fine amount (default 1,000), penalty type (warning/caution/fine/suspension/ban/termination), fine status (imposed/paid/waived/appeal_pending), paid date, suspension from/to. Ends in a client-computed membership summary (not the `/preview` endpoint — see Routes above).
## Validation — what actually gets checked
Client-side `validateStep()` runs **only when you click Next**, and the final submit button only **re-validates steps 1, 2, 3, and 5** (`stepsToValidate = [1, 2, 3, 5]`). **Steps 4, 6, and 7 (installments, subscriptions, violations) have no submit-time validation at all** — if you navigated past them without using Next, bad data in those steps can reach the server.
Server-side (`RetroactiveMembershipService`, before the transaction opens): full name, mobile phone, join date, form date, DOB-or-NID are re-checked, plus the same required fields per dependent (name, DOB-or-NID, and marriage date for spouses). All failing checks are collected and returned together as one newline-joined error string — you see everything wrong in one pass, not one field at a time.
**Exact messages, if you need to recognize one in a screenshot:**
- الاسم بالعربية مطلوب
- رقم الهاتف المحمول مطلوب
- تاريخ الانضمام / تاريخ الاستمارة مطلوب
- تاريخ الميلاد مطلوب (أو أدخل الرقم القومي 14 رقم)
- اسم الزوجة #{n} مطلوب / تاريخ زواج الزوجة #{n} مطلوب / تاريخ ميلاد الزوجة #{n} مطلوب
- اسم الابن/الابنة #{n} مطلوب / تاريخ ميلاد الابن/الابنة #{n} مطلوب
- اسم العضو المؤقت #{n} مطلوب / تاريخ ميلاد العضو المؤقت #{n} مطلوب
## What gets written on success
Everything below happens inside one DB transaction:
1. **`members`** — inserted with `status='active'` immediately, `created_at` backdated to `form_date 09:00:00`. A duplicate national ID aborts before insert. Membership number is assigned (or kept, if supplied) in a follow-up update. **A second follow-up update sets `activated_at`/`activated_by_payment_id` directly** — this bypasses `MembershipPaymentGuard`, which the Members architecture map documents as the module's *sole authority* for activation. Worth knowing if you're ever debugging why a member looks active without a guard-logged activation.
2. **`payments`** — one row per money event (form fee, membership fee or down payment, each paid installment, each dependent's separate fee, each paid subscription-year, each paid fine), every one tagged `[RETROACTIVE]` in its notes, dated to the historical date supplied. Any check payment is marked `cleared` immediately — retroactive checks skip the normal clearing lifecycle by design (the code comment explains: a retroactive entry documents something that already happened, so it must not invent a clearing fee nobody actually paid at the time).
3. **`receipts`** — one per payment, numbered `REC-{payment's year}-{seq}`, continuing the sequence for *that* year — meaning it can insert a receipt number into the middle of an already-issued year, not append to today's sequence.
4. **`spouses` / `children` / `temporary_members`** — one row each, active.
5. **`subscriptions`** — one row per year/person, deduplicated against existing rows, priced via the RuleEngine (see Step 6 above).
6. **`installment_plans`**, **`installment_schedule`** (one row/month), **`installment_cheques`** — full schedule computed server-side independently of the JS preview.
7. **`violations`** + **`fines`** — one pair per entry; a fine marked "paid" also creates a payment row.
8. **Events:** `payment.completed` fires once per payment created — the same event the real Cashier flow uses, so **it triggers the real Accounting listener** and auto-posts a journal entry (Dr. Cash/Bank, Cr. Revenue) dated to the historical payment date. `member.created` also fires with a `retroactive: true` flag, but nothing in the codebase currently listens for it.
On success: redirect to `/members/{id}` with "تم إنشاء العضوية بأثر رجعي بنجاح — رقم العضوية: {number}".
## Known defects (found during this review, not yet fixed)
These are real, verified in the code — flagging them here rather than silently fixing them mid-documentation pass, since a retroactive-entry tool touching accounting deserves a deliberate fix, not a rushed one:
1. **No accounting-period lock check.** Because posting rides the normal `payment.completed` event, a retroactive entry with an old `payment_date` can post a journal entry into a month that's already closed in إقفال الفترات. Nothing in `AccountingIntegrationService::onPaymentCompleted()` checks period status.
2. **Transaction can leak open.** Several server-side validation failures inside `createRetroactiveMember()` (e.g., a spouse's DOB still unresolved at insert time) `return` an error array directly instead of throwing, which skips the `catch` block that calls `rollBack()`. If a payment or dependent row was already inserted earlier in the same request, the transaction is left neither committed nor rolled back.
3. **Raw exception text can reach the Arabic UI.** The catch-all error path truncates the raw PHP exception message to 200 characters and shows it verbatim in the flash banner if it doesn't match one of the known "duplicate entry" / "foreign key" patterns — this can leak table/column names to whoever is running the wizard.
4. **Form state is lost on validation failure.** `_old_input` is flashed but never read back by the view; a failed submission means re-entering all seven steps.
## Related
`MemberController::show()` (redirect target) · `MemberNumberGenerator` · `NationalIdParser` · `RuleEngine` (subscription year pricing) · `AccountingIntegrationService` (auto-posting) · Members architecture map (`docs/architecture-maps/Members.md`), which lists this tool under its own Risk Areas as item 8: "Retroactive wizard creates payments without Cashier flow — can mismatch accounting."
# Wizard: معالج توزيع الإيرادات (Revenue Mapping Wizard)
`/accounting/revenue-mapping/{id}/wizard` · `Accounting\Controllers\RevenueMappingController` · permission `accounting.revenue_mapping.manage`
## Purpose
The guided, "carve off pieces and watch the remainder" front-end to the ERP's **revenue account-determination engine**. It configures a `revenue_posting_rule` (+ its lines) for one or more revenue streams at a given lifecycle stage (accrual / collection / payment / refund / writeoff / transfer). **It does not post any journal entry itself** — it only writes configuration that `RevenuePostingEngine` reads later, at the moment a real transaction (a payment, a fine, a sale) fires and needs to know which GL accounts to hit.
The controller's own doc-comment frames it precisely: *"the rule editor asks you to think in lines. The wizard asks the question the way a finance meeting asks it: here is 150,000 — carve a piece off, see what is left, carve the next, and whatever remains lands in the last account."* Same rules, same versioning, same engine as the manual editor at `/edit` — only the framing differs.
**This screen has no menu entry of its own.** It's only reachable by drilling into either **توزيع الإيرادات** (the streams list, which links to it per unconfigured or already-configured stage) or **مركز التوصيل** (the connection centre, which links to it only for already-connected streams — unconfigured streams there go to the manual `/edit` screen instead).
## Step ① — Scope: what will this rule apply to?
| Field | Options | Default |
|---|---|---|
| `scope` | `stream` (this one revenue source) / `category` (a whole category of sources) / `selection` (hand-picked list) / `unmapped` (everything not yet configured) / `all` | `stream` |
| `category` | any key from the category-stage map | the current stream's own category |
| `stage` | accrual / collection / payment / refund / writeoff / transfer | the stream's configured stage, or its primary stage if none configured |
| `member_category` | working_member / foreign_member / sports_member / honorary_member / seasonal_member, or blank for a general rule | blank |
A rule scoped to a specific member category **outranks** a general rule when the engine later resolves which rule to apply — this is the same specificity logic used for payment-method-scoped rules. A live info box always shows exactly how many posting-rule targets the current scope resolves to before you commit to anything.
There's also a **direction toggle** here ("غيّره لـ تحصيل/صرف") — a separate confirmed action (`POST .../direction`) that flips the stream's default direction (inflow/outflow) without touching any existing rule or posted entry.
## Step ② — Reference amount & metadata
The amount field itself (default: average of real recorded amounts for the stream, or 150,000 if none) is **illustrative only and never submitted** — it exists purely so percentage splits preview against a realistic number. What actually gets saved:
| Field | Required | Notes |
|---|---|---|
| `tax_profile_id` | no | any active tax profile — not filtered to the stream |
| `cost_center_id` | no | active cost centers (block hidden entirely if none exist) |
| `branch_id` | no | active branches (block hidden if none exist) |
| `effective_from` | yes, defaults to today | rejected if it falls inside an already-closed accounting period |
| `notes` | no | free text, e.g. "قرار مجلس الإدارة رقم …" |
| `debit_source` | fixed | **always `auto_treasury`, hardcoded, hidden.** The wizard cannot set `fixed_account`/`accounts_receivable`/`accounts_payable` — only the manual `/edit` screen exposes those. |
## Step ③ — Carve off the parts (repeatable split lines)
Each line: allocation method (percentage of net / fixed amount), the value itself, an account (live-search, AJAX — see account-picker scope below), a line type, an optional description.
**Line types depend on direction:** inflow streams offer revenue / deferred_revenue / passthrough / contra_revenue / receivable_offset / equity; outflow streams offer expense / prepaid_expense / asset / inventory / payable_offset / writeoff / passthrough.
If line type = **deferred revenue**: a recognition-months field (1–120, default 12) plus a "recognized account" picker, restricted to `account_type = 'revenue'` accounts only.
If **appropriation** is checked (inflow lines only — an earmark, not a new revenue type): two additional required account pickers — where the full revenue records first (restricted to `account_type='revenue'`), and where the appropriated amount is deducted from (any active leaf account, e.g. retained earnings).
**Account-picker scope, precisely:** every generic picker (main line, remainder, appropriation source) searches `chart_of_accounts WHERE is_archived=0 AND is_active=1 AND is_header=0` — i.e. any active leaf account of any type, max 60 results per query. The deferred-revenue and appropriation-revenue pickers additionally filter `account_type='revenue'`. A "+ حساب جديد" (new account) button lets you create a leaf account inline, but only under an existing **header** account — attempting to nest under a non-header account fails with *"… حساب فرعي عليه حركة — لا يصلح كحساب أب."*
## Step ④ — The remainder line
Exactly one remainder line is required — the form blocks submission without it. Same account-picker scope as above (unfiltered by type), plus its own line-type selection.
## Client-side pre-submit checks (block the save entirely, no server round-trip)
- No remainder account chosen
- A line has a value but no account
- A deferred-revenue line has no recognized account
- An appropriation line is missing either of its two required accounts
- The remainder line is itself marked as an appropriation (not allowed)
- Percentages sum above 100%
- The chosen scope resolves to zero targets
If the scope resolves to more than one rule, a confirmation dialog states exactly how many: *"هتطبّق نفس التقسيمة على N قاعدة قيد. تمام؟"*
## What happens on save — every write, precisely
`applyPlan()``AllocationPlanService::apply()`, one DB transaction across **all** resolved targets:
For each `(stream, stage)` pair:
1. Computes the next version number for that stream+stage.
2. **Inserts** a new `revenue_posting_rules` row: `status='active'`, chosen effective date/direction/tax profile/cost center/branch/member category/notes, `debit_source='auto_treasury'`.
3. **Inserts** one `revenue_posting_rule_lines` row per split line.
4. **Supersedes** the prior active rule *in the same scope* (same branch, payment method, member category — matched with NULL-safe equality, so a general rule and a member-category-scoped rule never step on each other): sets `status='superseded'`, `effective_to = effective_from - 1 day`, links `superseded_by_id`.
5. Clears the stream's "needs review" flag.
**No `journal_entries` row is ever created by this wizard.** That only happens later, when a real transaction fires and `RevenuePostingEngine::post()` resolves the now-configured rule.
If any target fails validation, **nothing is written at all** — the whole batch is atomic.
## The two business-rule guards worth knowing about (not obvious from the UI)
1. **Double-revenue-recognition guard** (only active if `system_config['accounting.enforce_accrual_before_collection']='1'`): if a stream already recognizes revenue at the accrual stage, a *collection*-stage line configured to also record fresh revenue is rejected: *"البند … بيسجّل إيراد وقت التحصيل، والمصدر ده بيسجّل إيراده وقت الاستحقاق. التحصيل هنا تسوية ذمم — استخدم نوع البند «تسوية ذمم مدينة»."*
2. **Deferral-required guard** (categories listed in `system_config['accounting.deferral_required_categories']`, default `subscription`): a line recording revenue immediately for one of these categories is rejected — it must use `deferred_revenue` and post monthly instead: *"البند … بيسجّل الإيراد كامل فورًا. الفئة دي الخدمة فيها بتتقدّم على مدى فترة، فلازم نوع البند يكون «إيراد مؤجل»."*
Also: mixing inflow and outflow streams in one bulk scope is rejected outright — *"النطاق ده فيه مصادر تحصيل ومصادر صرف مع بعض — قسّمهم على دفعتين."*
## Permissions
The wizard route itself requires `accounting.revenue_mapping.manage` (not `.view`) — a view-only user cannot open it at all. Every mutating action (apply, direction toggle, create-account) also requires `.manage`; the account-search/parent-account lookups only require `.view`.
## How it differs from the manual `/edit` screen
Both write to the identical tables via the identical versioning logic — the wizard literally delegates to the same `AllocationPlanService` the single-stream editor path mirrors. The real differences: (a) the wizard can push one split onto **many streams/stages at once** (bulk scope), while `/edit` only ever touches one stream+stage; (b) the wizard hides `debit_source` entirely (always `auto_treasury`), while `/edit` exposes the full set including `fixed_account`/`accounts_receivable`/`accounts_payable`.
## Related services (for context, not configured here)
`GapToolService` — a distinct, adjacent tool for **valuing** events that have no amount yet (e.g. pool-zone bookings) before this wizard can map them anywhere; reached from a separate سد الفجوات screen, not linked from this wizard. `PostingRouter` — the single door every module's event listener calls at transaction time, which falls back to legacy hardcoded account codes if a stream/stage isn't configured through this wizard. `RevenueRecognitionService` — consumes the `deferred_revenue` lines this wizard writes, laying down monthly recognition schedules (reachable via the الإيراد المؤجل menu item). `RevenueStreamRegistry` — the catalogue sync job that *creates* the stream rows this wizard maps; run it first if a new payment type isn't showing up here at all.
# Wizard: معالج الحجز (SA Hourly Booking Wizard)
`/sa/booking-wizard` · `SportsActivity\Controllers\BookingWizardController` · permission `sa.booking_wizard.use`
## Purpose
Books a paid hourly slot on any active facility unit (court, lane, room) for a member or guest: identity lookup → pick a facility/unit → pick a date/time slot → confirm and send to the treasury queue. Distinct from the pool-reservations wizard (which sells lane-rental/session-card *packages* to freelance coaches, not individual hourly slots) and from the group registration wizard.
## Architecture note — pure client-side wizard
Unlike the registration wizard (state re-derived from DB flags on every load), this one keeps **all** state in an in-page JS object and never persists it between steps server-side — no session key, no draft-booking row, no hidden carry-over field. **A page refresh at any step loses everything and restarts at step 1.** The 4 steps are pure `display:none/''` toggles in the browser; the server is only consulted for three lookups (`lookup`, `units`, `slots`) plus the one final `book` submission that sends the entire accumulated state at once.
## Routes
| Method | Path | Handler |
|---|---|---|
| GET | `/sa/booking-wizard` | `index` |
| POST | `/sa/booking-wizard/lookup` | `lookup` |
| GET | `/sa/booking-wizard/units` | `units` |
| GET | `/sa/booking-wizard/slots` | `slots` |
| POST | `/sa/booking-wizard/book` | `book` |
All gated by `sa.booking_wizard.use`. One related endpoint used from step 3, **`GET /api/sa/bookings/price-preview`, requires a *different* permission `sa.booking.view`** — if the employee has the wizard permission but not this one, the endpoint 403s and the price preview silently shows "—" with no error message (the fetch's `.catch()` swallows it).
## Step 1 — Identity
Fields: membership number (debounced 400ms lookup), national ID (fires lookup only at exactly 14 digits), name (**required** — only client-enforced, controls whether Next is enabled), gender/age (read-only display, derived from lookup or NID parse, never submitted).
`lookup()` has effectively no server-side validation beyond "at least one of national_id/membership_number/name must be present" (**"أدخل رقم العضوية أو الرقم القومي أو الاسم"**). Resolution: membership number → `checkByMembershipNumber()`; else 14-digit NID → parse + `checkByNationalId()`. `MembershipValidationService::validateMember()` then applies the standard active/subscription checks (status must be `active`; unless the member activated within the current financial year, an annual subscription paid for the current FY is also required — **"لم يسدد الاشتراك السنوي (FY)"**).
National-ID parse errors (all from the shared `NationalIdParser`, reused across every wizard in this system): not 14 digits, non-digit characters, invalid century code, invalid month/day, impossible calendar date, DOB in the future, age over 120, unknown governorate code — each has its own exact Arabic message (see the Errors table below).
## Step 2 — Facility/unit
Not a form — a searchable clickable card grid, sourced from:
```sql
SELECT fu.*, f.name_ar, f.facility_type, f.operating_hours_json
FROM sa_facility_units fu JOIN sa_facilities f ON f.id = fu.facility_id
WHERE fu.is_active = 1 AND f.is_archived = 0
ORDER BY f.name_ar, fu.name_ar
```
**Every active unit in every non-archived facility club-wide is listed** — no branch or discipline filtering. Search box filters client-side only (uses the shared `ArabicSearch` core if present). Clicking a card is itself the "next" action — no separate button.
## Step 3 — Date & time slot
Date picker (default today) + participant count (min 1, **no client-side max** — the real capacity check only happens server-side at final submit) + a generated slot grid.
Slot generation (`slots()`) walks the facility's `operating_hours_json` (fallback 06:00–22:00, 60-min slots) and marks each slot `free`/`partial`/`occupied`/`blackout` by checking `sa_blackout_dates` and existing overlapping bookings (exclusive units: any overlap = occupied; shared units: remaining capacity computed from `spots_reserved`). **This preview does not consult `sa_pool_zone_bookings`**, while the authoritative check at final booking time does — a slot shown "free" here can still be rejected in step 4 for pool-grid facilities. This is a real gap between preview and enforcement, not a documentation nuance.
## Step 4 — Confirm & book
Only field: notes (optional). Confirm button POSTs the entire state to `/sa/booking-wizard/book`:
Server validation (`Validator`): `unit_id` required|integer, `date` required|date, `start_time`/`end_time` required|string, `participants` required|integer|min:1, `booker_name` required|string|min:2|max:200 — **any failure collapses to one generic message: "بيانات الحجز غير مكتملة"**, the client never learns which field failed.
**Dead code found:** the controller reads `organization_name`, `organization_contact`, `participant_mode` from POST and can derive an `organization` booker type, but this wizard's JS (and the classic non-wizard booking form) never sends or offers these fields anywhere in the UI — the organization booking path is currently unreachable from any screen.
Booking creation (`BookingService::createHourlyBooking()`):
1. **Server re-checks availability from scratch** (`SlotAvailabilityService::check()`), ignoring whatever the step-3 grid displayed. Failure messages: unit not found/inactive → **"الوحدة غير موجودة أو غير مفعلة"**; blackout overlap → **"المرفق محجوب في هذا التوقيت"**; all pool lanes blocked (grid facilities) → **"جميع حارات الحمام مشغولة في هذا التوقيت (شبكة الحمام)"**; exclusive unit already booked → **"الوحدة محجوزة بالفعل في هذا التوقيت"**; shared unit over capacity → **"السعة غير كافية. المتبقي: {remaining}، المطلوب: {requested}"**.
2. Pricing via `PricingCalculatorService::calculate()`. **If no matching pricing rule exists, price silently falls back to 0.00 and the booking still proceeds** — no error surfaced.
3. Sequential booking number `BK-YYYYMMDD-NNNN`.
4. Inserts `sa_bookings` (`status='confirmed'`, `payment_status='unpaid'`), then `sa_booking_passes` rows (one per participant, `<number>-P01` etc, since `participant_mode` defaults implicitly to `'passes'` from this UI).
5. If `total_amount > 0`: creates a `payment_requests` row (`payment_type='hourly_booking'`) and sets `payment_status='pending'`. **If that request-creation call fails, the failure is silently swallowed**`payment_status` stays `unpaid` and the client is never told.
6. If `total_amount <= 0` (free booking, e.g. because no pricing rule matched), no payment request is created at all, yet the success screen unconditionally shows "بانتظار التحصيل من الخزينة" (awaiting treasury collection) — misleading for a genuinely free booking.
On success: JSON with booking number/total — no redirect, the page swaps to a success panel in place. Downstream, cashier collection of the payment request fires `payment_request.completed``SaEventListenerService::handleBookingPaid()` sets `payment_status='paid'`.
## Permissions
| Route | Permission |
|---|---|
| All 5 booking-wizard routes | `sa.booking_wizard.use` |
| `/api/sa/bookings/price-preview` (used in step 3) | `sa.booking.view` — a **different** key, and the endpoint has no `authorize()` call of its own, relying only on `AuthMiddleware` |
## Full error table
| Condition | Message |
|---|---|
| No identity field given | أدخل رقم العضوية أو الرقم القومي أو الاسم |
| Membership number not found | رقم العضوية غير موجود |
| Member status inactive | dynamic label (عضوية مجمدة / موقوفة / منتهية …) |
| Annual subscription unpaid | لم يسدد الاشتراك السنوي (FY) |
| NID not 14 digits | الرقم القومي يجب أن يكون 14 رقم |
| NID non-digit | الرقم القومي يجب أن يحتوي على أرقام فقط |
| NID century code invalid | رمز القرن غير صالح (يجب أن يكون 2 أو 3) |
| NID month/day invalid | الشهر غير صالح / اليوم غير صالح |
| NID impossible date | التاريخ غير صالح: YYYY-MM-DD |
| NID DOB in future | تاريخ الميلاد في المستقبل |
| NID age > 120 | العمر غير منطقي (أكثر من 120 سنة) |
| NID unknown governorate | كود المحافظة غير صالح |
| No unit chosen for slots | اختر الوحدة |
| Unit not found | الوحدة غير موجودة |
| Booking payload fails validator | بيانات الحجز غير مكتملة (generic) |
| Unit not found/inactive at booking | الوحدة غير موجودة أو غير مفعلة |
| Blackout overlap | المرفق محجوب في هذا التوقيت |
| All pool lanes blocked | جميع حارات الحمام مشغولة في هذا التوقيت (شبكة الحمام) |
| Exclusive unit already booked | الوحدة محجوزة بالفعل في هذا التوقيت |
| Shared capacity exceeded | السعة غير كافية. المتبقي: X، المطلوب: Y |
| DB error during booking insert | فشل إنشاء الحجز: {exception text} |
| CSRF expired mid-wizard | انتهت صلاحية الجلسة — يرجى تحديث الصفحة (HTTP 419, special-cased) |
| Missing wizard permission | HTTP 403 |
## Not handled (real gaps, not just missing polish)
- No coach-conflict check — irrelevant here since the wizard never collects a coach, but worth knowing the check exists elsewhere (`ConflictDetectionService`, only used for training bookings).
- No max-participants-vs-capacity guard client-side; only enforced at final server submit, after 3 steps of user effort.
- Slot-grid preview vs. authoritative check disagree on pool-grid facilities (see Step 3).
- Silent payment-request-creation failure leaves a booking in a permanently `unpaid` limbo with no visible error.
## Related screens
`/sa/bookings` (list) · `/sa/bookings/create` (the classic non-wizard equivalent — only offers guest/member/employee booker types) · `/sa/bookings/{id}` (detail, check-in/out) · `/sa/bookings/{id}/passes` · cancel/postpone actions (max 3 postponements enforced) · gate/turnstile pass validation · Cashier queue (where the payment request actually gets collected) · `/sa/pricing/rules` (where `sa_time_brackets`/`sa_pricing_rules` are configured — an unconfigured slot silently prices at 0) · Facilities/units admin screens (where `operating_hours_json` and unit capacity are set).
# Wizard: حجوزات السباحة (Swimming Pool Reservations Wizard)
`/sa/swimming/pool-reservations/wizard` · `SportsActivity\Controllers\Swimming\PoolReservationController` · permission `sa.pool_reservation.create`
## What this actually is (correcting the obvious assumption)
Despite the name, this is **not** a pool-zone/time-slot grid booking screen. It never touches `sa_pool_zone_templates` or the Facility Grid tables at all. It's a **sales wizard**: it sells a lane-rental or session-card *package* (a fixed number of sessions at a fixed price) to a booker — typically a freelance coach or an outside entity — and creates a synthetic "group" to hold the sessions. **Actual scheduling happens afterward, manually, in the separate Facility Grid drag-and-drop screen** (`/facility-grids/{gridId}`) — the detail page for an unscheduled reservation says so in plain text: *"لم يتم جدولة حصص بعد — اسحب المجموعة في المراية لتحديد المواعيد."*
## Architecture — single-page, no server-side step tracking
One `GET` route renders one page with all 3 steps + inline JS; state lives entirely in a client-side object and is never persisted between steps. **A page refresh at any point restarts at step 1.** The only server round-trips before final submit are a one-time pricing fetch (cached client-side) and, optionally, a capacity-check endpoint that **the wizard's own JS never actually calls** (see Known Issues).
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sa/swimming/pool-reservations` | `list` | `sa.pool_reservation.view` |
| GET | `/sa/swimming/pool-reservations/wizard` | `wizard` | `sa.pool_reservation.create` |
| GET | `/sa/swimming/pool-reservations/{id}` | `show` | `sa.pool_reservation.view` |
| POST | `/sa/swimming/pool-reservations/{id}/use-session` | `recordSession` | `sa.pool_reservation.manage` |
| POST | `/sa/swimming/pool-reservations/{id}/cancel` | `cancel` | `sa.pool_reservation.manage` |
| GET | `/api/sa/swimming/pool-reservations/pricing` | `pricing` | `sa.pool_reservation.create` |
| POST | `/api/sa/swimming/pool-reservations/capacity-check` | `capacityCheck` | `sa.pool_reservation.create`**dead, never called** |
| POST | `/api/sa/swimming/pool-reservations/store` | `store` | `sa.pool_reservation.create` |
## Step 1 — Booker info
Fields: booker name (**required**, only client-enforced), phone (optional), booker type (select: freelance_coach / entity / individual — **hardcoded in the view, not DB-driven**), participant count (number, 1–100, clamped client-side). No server call on this step at all.
## Step 2 — Package selection
On entry, fetches `GET /api/sa/swimming/pool-reservations/pricing` once, returning two lists from `sa_academy_pricing` (`category IN ('lane_rental','session_card')`, active, within effective date range):
- **Lane rentals**, grouped by `lane_type` (50m/25m/mix), each with `sessions_per_month` and price. Seeded prices: 50m → 12/8/4/1 sessions at 4100/2750/1400/410 EGP; 25m → 3000/2100/1200/350; mix → 24 sessions at 6350.
- **Session cards**: `total_sessions` at a flat price — 24/12/8/6/4/1 sessions at 1450/800/580/520/440/140 EGP.
Selecting a lane package triggers an overflow check: the wizard has a **hardcoded, client-side-only** lane-capacity table (`{50m:12, 25m:8, mix:24}`) duplicated from a server-side constant — if `participant_count` exceeds it, the user must additionally pick "extra lane" or "overflow session cards," priced by re-using the session-card list. Session-card packages have no capacity concept, so this check doesn't apply to them.
**No field on this step is server-validated for correctness before final submit** — the price, session count, and overflow amount the client computes are trusted as-is at `store()` time (see Known Issues).
## Step 3 — Confirm
One field: notes (optional). Confirm button (**"تأكيد وإرسال للخزينة"**) POSTs to `/api/sa/swimming/pool-reservations/store` with the full accumulated state.
## Server-side validation on submit (`PoolReservationService::create()`)
| Condition | Message |
|---|---|
| Booker name blank | اسم الحاجز مطلوب |
| Reservation type not one of lane_50m/lane_25m/lane_mix/cards | نوع الحجز غير صالح |
| Sessions total < 1 | عدد الحصص غير صالح |
| Unit price ≤ 0 | السعر غير صالح |
| Matching `sa_programs` row missing | برنامج الإيجار غير موجود — يرجى تشغيل المايقريشن |
| Any DB exception | فشل إنشاء الحجز: {raw exception text — leaked verbatim to the UI} |
**Not validated at all:** participant count (only clamped to a minimum of 1), phone format, and — critically — the price/session-count/overflow figures themselves are taken as-is from the client, not recomputed from `sa_academy_pricing`. The `pricing` and `capacity-check` endpoints are advisory only; nothing stops a crafted request from posting an arbitrary price.
## What gets written on success
Inside one transaction:
1. **`sa_groups`** — a synthetic group is created to host the future sessions (`source_type='pool_reservation'`, `coach_id=null`, `max_capacity=current_count=participant_count`, `is_full=0` — note it's created already at full capacity by definition but flagged as not full).
2. **`sa_pool_reservations`** — the reservation itself: `reservation_number` (`PR-YYYYMMDD-NNNN`, generated by a `MAX()+1` query with **no locking** — two concurrent submissions on the same day can race into a duplicate, which then throws on the table's unique constraint and surfaces as the generic DB-exception message above), `total_amount = unit_price + overflow_amount`, `payment_status='pending'`, `status='active'`, `expiry_date = today + 2 months` (hardcoded).
3. `sa_groups.pool_reservation_id` backfilled.
After the transaction commits, if `total_amount > 0`, a `payment_requests` row is created (`payment_type='pool_reservation'`, `member_id=0` — explicitly whitelisted for this type since a freelance coach isn't a member). **The resulting request ID is never written back onto `sa_pool_reservations.payment_request_id`** — a column that exists specifically for this — which breaks the reverse lookup the void-handling listener needs; voiding this payment in the treasury will silently fail to revert the reservation's payment status.
Cashier collection later fires `payment_request.completed``handlePoolReservationPaid()`, which sets `payment_status='paid'` and a `payment_id`.
## Known issues found in this review
- **`capacityCheck` endpoint is fully built server-side but never called** — the wizard reimplements the same math in raw client JS with a duplicated, hand-maintained capacity table that will silently drift if the server-side constant ever changes.
- **No server-side re-validation of price or overflow amount** — advisory-only client math is trusted at submit time.
- **`payment_request_id` never persisted** on the reservation, breaking void-reversion.
- **Cancelling a reservation does not cancel its pending payment request** — it's left dangling in the treasury queue (a consequence of the same missing link above).
- **No branch scoping at all**`sa_pool_reservations.branch_id` exists in the schema but this flow never populates it; reservations are effectively branch-less club-wide, unlike most other screens in the system.
- Possible schema/code mismatches worth checking directly against the live DB before relying on them: `sa_groups.coach_id` may be `NOT NULL` in the original migration while this flow inserts `null`; `handlePoolReservationPaid()` writes to a `payment_id` column that the recorded migration for `sa_pool_reservations` never defines. If either is true on the live database, the affected write silently fails (caught and logged, not surfaced) and the reservation's payment status can get stuck.
- Raw exception text is shown verbatim to the end user on any DB failure during creation.
## Related screens
List (`/sa/swimming/pool-reservations`, launch point for "حجز جديد") · detail/show screen ("تسجيل حصة" decrements remaining sessions and auto-completes + archives the group when they hit zero; "إلغاء الحجز" cancels both the reservation and its group) · **Facility Grid** (`/facility-grids/{gridId}`) — where the created group actually gets scheduled onto real lanes/times; legacy `/pool/{id}/grid` URLs redirect here · Groups module (every reservation spawns one, but the detail page's "view group" link always points at the generic group list, not the specific group) · Cashier/Treasury queue · pricing source (`sa_academy_pricing`, currently only maintainable via migration/seed — no admin CRUD screen for these prices was found in this module).
# Wizard: تسجيل لاعب جديد (SA Player Registration)
`/sa/registration` · `SportsActivity\Controllers\RegistrationWizardController` · permissions `sa.registration.view` (report only), `sa.registration.manage` (everything else)
## Purpose
Registers a new (or returning) player into the Sports Activity system: identity + membership check → registration-fee payment → photo capture → discipline-of-interest selection → (handoff to a *separate* Coach Assessment screen for group placement + subscription) → card issue. It is a **state-machine wizard**, not a linear POST chain: every `GET /sa/registration/{id}` recomputes the current step from DB flags via `determineStep()`, so a page refresh always lands correctly.
```php
// RegistrationWizardController.php:428-447
if (in_array($registration['status'], ['completed','assessed'], true)) return 4;
if (!empty($registration['selected_disciplines'])) return 4;
if ((int) ($registration['photo_captured'] ?? 0) === 1) return 3;
if (($registration['form_payment_status'] ?? 'unpaid') === 'paid') return 2;
return 1;
```
## Route table
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sa/registration/report` | `report` | `sa.registration.view` |
| GET | `/sa/registration` | `index` | `sa.registration.manage` |
| POST | `/sa/registration/lookup` | `lookupPlayer` | `sa.registration.manage` |
| GET | `/sa/registration/{id}` | `wizardStep` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/pay-form` | `submitFormPayment` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/photo` | `uploadPhoto` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/activity` | `selectActivity` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/pay` | `submitPayment` | `sa.registration.manage`**dead stub, always fails** (see below) |
| GET | `/sa/registration/{id}/print-form` | `printForm` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/generate-card` | `generateCard` | `sa.registration.manage` |
| GET | `/sa/registration/{id}/print-card` | `printCard` | `sa.registration.manage` |
| POST | `/sa/registration/{id}/cancel` | `cancel` | `sa.registration.manage` |
Note `report` is registered before `{id:\d+}` so the literal path never falls into the numeric-id route.
**An employee with only `sa.registration.view` can see the aggregate report but cannot open the wizard at all**`sa.registration.manage` gates every operational route. The two permissions are independent; holding one does not imply the other.
## Entry: player lookup
`POST /sa/registration/lookup`. Resolution order:
1. Membership number given → `MembershipValidationService::checkByMembershipNumber()`.
2. Else 14-digit national ID → `NationalIdParser::parse()` (extracts DOB/gender) + `checkByNationalId()`.
3. If a member is resolved *and* the typed national ID doesn't match that member's own NID on file, the match is **discarded**: reverts to `player_type=non_member`, `member_id=0`, reason *"الرقم القومي لا يطابق بيانات العضو"* — stops someone claiming another member's discount with a mismatched NID.
4. Neither name nor a valid NID present → **"أدخل رقم العضوية أو الرقم القومي أو الاسم"**.
5. **Duplicate-in-flight check**: an existing registration (status `in_progress|pending_payment|completed|assessed`) for the same NID/member is looked up first.
- Already `completed`/`assessed` **and** card generated → hard stop: **"هذا اللاعب مسجل بالفعل وأكمل جميع الخطوات — لا يمكن التسجيل مرة أخرى"**.
- Otherwise **resumes** the existing registration (no duplicate row) — redirects straight to `/sa/registration/{id}` at whatever step it's at.
6. Otherwise creates a new player (if not found by NID/member_id) and a new `sa_registrations` row.
New-player creation requires `full_name_ar` (**"الاسم بالعربي مطلوب"**); generates a sequential `SAP-00001` serial; sets `medical_status='pending'`, `card_status='inactive'`.
**Registration fee: flat 50 EGP members / 100 EGP non-members**, hardcoded in `calculateFees()` — this **supersedes** the `system_config` keys `sa.registration_fee_member/nonmember` (set to a flat 100/100 by an old migration); those config keys are effectively dead. If the resolved *existing* player already has `sa_players.registration_fee_paid=1` (from a prior registration), the new registration is created with the fee pre-waived (`registration_fee=0`, `form_payment_status='paid'`) — a returning player never pays the form fee twice, and this jumps straight past step 1.
## Step 1 — Pay registration fee
No form fields — one button, **"إرسال للخزينة — {fee} ج.م"**`POST .../pay-form` → creates a `payment_requests` row (`payment_type='sa_registration_fee'`) and sets `form_payment_status='pending'`. The screen then just shows a static banner **"تم إرسال طلب الدفع — بانتظار التحصيل من الخزينة"**; there is no polling, the operator must reload after the cashier collects it. Collection fires `payment_request.completed``SaEventListenerService::handleRegistrationFormPaid()` sets `form_payment_status='paid'` and `sa_players.registration_fee_paid=1`, which is what advances `determineStep()` to step 2.
## Step 2 — Photo capture
Webcam capture (`canvas.toDataURL``photo_base64`) or file upload, both hit `POST .../photo`.
- Base64 decode failure or payload under 100 bytes → **"فشل معالجة الصورة"**.
- No file/base64 at all → **"لم يتم رفع صورة"**.
- Registration not `in_progress`**"التسجيل غير موجود أو مكتمل"**.
- `PhotoUploadService` validation (shared across the system): size &gt; 5MB → **"حجم الصورة يتجاوز الحد المسموح (5 ميجابايت)"**; unsupported MIME → **"صيغة الصورة غير مدعومة — يُقبل فقط JPG, PNG, WEBP"**; not a real image → **"الملف ليس صورة صالحة"**; dimensions &gt; 8000×8000 → **"أبعاد الصورة كبيرة جداً (الحد الأقصى 8000×8000)"**.
On success: recompresses to JPEG ≤800×800 @ quality 82 + a 200×200 thumbnail, stores under `public/uploads/photos/sa_players/`, sets `sa_registrations.photo_captured=1`. The "Next" button only enables once this flag is server-confirmed true.
## Step 3 — Discipline (interest) selection
Checkbox grid of active `sa_disciplines`. **At least one required****"يجب اختيار نشاط واحد على الأقل"** (both client and server). Saves as a JSON array `{id,name}` into `sa_registrations.selected_disciplines`**this is only a declaration of interest, not a group assignment.** Group placement happens in the separate Coach Assessment screen.
There is dead code in `RegistrationWizardService` (`selectGroup()`/`selectProgram()`, an older pre-refactor version that directly assigned a group and computed sibling/seasonal discounts) that the current controller never calls — confirmed superseded by `Phase_100_010_sports_wizard_refactor.php`.
## Step 4 — Complete
Message varies by state: `payment_status='paid'` → ready to generate the card; `status='assessed'` (subscription unpaid) → waiting on cashier; otherwise → waiting on the coach's technical assessment.
**Important: the wizard alone never reaches "paid."** Full completion requires the separate **Coach Assessment** screen:
1. Coach picks a group + skill level for a player whose form fee is paid and disciplines are selected (`CoachAssessmentController@assess`, permission `sa.coach_assessment.manage`).
- Duplicate enrollment → **"اللاعب مسجل بالفعل في هذه المجموعة"**.
- Group full → **"المجموعة ممتلئة — السعة القصوى {n}"**.
- Group missing/inactive → **"المجموعة غير موجودة أو غير نشطة"**.
- Group not selected → **"يجب اختيار المجموعة"**; skill level not selected → **"يجب تحديد المستوى"**.
- Form fee not yet paid → **"التسجيل غير موجود أو لم يتم دفع رسوم الاستمارة"**.
- **No medical-clearance check happens here at all**`CoachAssessmentService::assess()` performs no medical check. (The medical-grace-period logic that *does* exist in the codebase — `EnrollmentService.php`, warns but still allows enrollment with a 14-day default grace deadline — only applies to the separate direct-group-enrollment screen, not this wizard's completion path. Worth knowing if medical clearance is expected to gate registration; today it doesn't.)
- On success: inserts `sa_group_players`, increments the group's `current_count`, sets `sa_registrations.status='assessed'`, and — if the group's monthly fee &gt; 0 — creates an `sa_subscriptions` row plus a second `payment_requests` row (`payment_type='sports_subscription'`).
2. Cashier collects the subscription payment → `handleSubscriptionPaid()` sets `sa_subscriptions.payment_status='paid'` and flips the matching registration to `status='completed'`, `payment_status='paid'`.
3. Only now does **"إنشاء الكارت"** enable. It inserts an `sa_player_cards` row (`SAC-{year}-000001`, QR-encoded, `valid_until = today + subscription months`), sets `card_generated=1`, `sa_players.card_status='active'`.
**Caution on the QR codes:** `QRCodeGenerator::encode()` is not a real QR encoder — it concatenates a prefix + data + an md5 fragment and draws a hash-derived grid pattern, not actual QR error-correction data. Gate scanners relying on scanning these should be verified against what they actually expect.
## Dead/legacy paths worth knowing about
- `POST /sa/registration/{id}/pay` (`submitPayment`) **unconditionally returns failure**: *"تم إلغاء هذه الخطوة — الدفع يتم في خطوة الاستمارة"* — the route exists only for backward compatibility; the real payment flow is the two-payment-request chain above (form fee, then subscription).
- `/api/sa/registration/disciplines` and `/api/sa/registration/programs/{id}` are exposed but **not called** by the current wizard view — disciplines are loaded server-side inside `wizardStep()` instead.
- `RegistrationWizardService::completeRegistration()` is a full legacy one-shot completion method, only reachable if a payment request were ever created with `related_entity_type='sa_registrations'` — nothing in the current wizard creates one, so it is unreachable dead code today.
## `/sa/registration/report`
Date-ranged (`from`/`to`, defaults: first-of-month → today) summary: total/completed/cancelled/pending registrations, total revenue (completed only), member vs non-member counts, cards issued, forms printed — plus a per-day breakdown table. No CSV/PDF export on this specific screen (unlike the broader `/sa/reports/players|finance`, which do export).
## Related screens
Player Affairs/Players list (same `sa_players` table) · Player Documents/Medical Records (sets `sa_players.medical_status`, independently of this wizard) · **Coach Assessment** (the mandatory next step) · Cashier/Payment Requests queue (both fees are collected here, not in the wizard) · SA Player Cards screen (suspend/revoke/reactivate/renew what this wizard issues) · Gate Access (consumes the card) · Member Lookup API (shared with the Swimming registration wizard, which calls the same `startRegistration()` to create/find players) · SA Reports module (broader export-capable reporting).
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