# El Captain Sports Management

Modular ERP for sports organisations. Activity-agnostic: any sport is a
configuration of the same business model.

**Stack:** Laravel 13 / PHP 8.4 · PostgreSQL 16 · Livewire 4 + Alpine + Tailwind 4 ·
Arabic-first (`ar` default, bilingual) · CapRover deploy.

**Layout:** `app/Domain/{Module}/{Models,Services,Events,Enums}`,
`app/Domain/Shared/Traits`, `app/Livewire/{Feature}`, `resources/views/website`.

---

## Deployment reality — read before writing any migration

Every client is **one CapRover app + one dedicated Postgres DB**, all building
from git **`main`**. There is no staging. `docker/entrypoint.sh` runs
`migrate --force` **and** `db:seed` on **every container start**.

Therefore, non-negotiable:
- Migrations are **additive**; guard every `up()` with `hasTable`/`hasColumn`.
- Destructive operations live in `down()` only — never in `up()`.
- Seeders must be **idempotent** and must never contain client-specific content.
- Any migration must be safe against every existing populated client DB.

### The repository is what every client has

Treat this repo as **common ownership**: it defines what *every* client gets. One product,
installed once per client. Before touching anything, ask one question — **is this for all
clients, or for one specific client?**

| Scope | Where it goes |
|---|---|
| **All clients** — code, schema, **and records every client needs**: lookup tables, reference data, permissions, default settings | The repo. Code is committed and pushed; anything touching the database, structural *or* data, is a migration. It runs on every instance on the next deploy. |
| **One specific client** — a fix, an edit, or data requested for that instance | SSH to the server and apply it to **that client's database only**. It never enters the repo. |

**Data is not the deciding factor.** Records every client needs belong in a migration
exactly as schema does — seeding a permission or a lookup row from a migration is the
correct pattern, not a workaround (see
`2026_09_01_000001_add_branches_view_all_permission.php`). The only thing that must never
enter the repo is something specific to one client, because a migration applies to every
tenant at once and there is no way to scope it to one.

**We do not deploy.** Code is committed and pushed; the platform builds and ships it.
Do not trigger a CapRover build or force a service update.

## Hard invariants (violating these breaks production)

**Money** — all amounts are `bigInteger` **piasters**, never decimal/float.
150.00 EGP = `15000`. Convert only in `format_money()` for display. Integer
arithmetic only; when splitting, round down and give the remainder to the last item.

**Tenancy** — every domain model uses `BelongsToAcademy`. Every tenant table has
`academy_id`. Every unique constraint **includes `academy_id`**. Only
`academies`, `permissions`, and framework tables are exempt. SuperAdmin is the
only code path that may bypass the global scope.

**Revenue routing** — never hardcode which revenue account a collection lands
in. `RevenueRouter` reads the academy's own rules (`revenue_mappings`), takes
tax out **first** to a **liability** account, then splits the net: flat amounts,
then percentages, then one `remainder` line. The credits must sum to the money
that arrived — `assertBalanced()` throws before anything is written. Rules are
edited on `financial.revenue-routing`, never by deploy.

**Financial** — a `transactions` row carries **both sides**
(`debit_account_id` + `credit_account_id`); there is no pair of rows and no
`account_id` column. Amounts are always positive; `type` says what kind of
movement it was, not which side. Accounts resolve **by code, scoped to the
academy**, through `LedgerAccountResolver` — a missing account is a hard fail,
never a hardcoded id. `transactions` and `audit_logs` are treated as
**immutable**: corrections are new reversing entries, never edits. Invoice
totals freeze at creation.

**Livewire 4** — a plain `public` property is **settable from the browser**.
Validating an id in `mount()` and then filtering queries on it in `render()` is
an IDOR, not a check. Use `#[Locked]` *and* re-validate where the query is
built.

**Inventory** — never touch `inventory_levels.quantity_on_hand` directly. All
changes go through `InventoryService::createMovement()`, which locks the row and
records before/after. Stock may reach 0, never negative.

**Pricing** — no fixed prices; every price is computed at sale time. **No active
base price = hard fail** (throw, block the sale, show `لا يوجد سعر محدد`).
Never default to 0 or guess. Prices on an issued invoice are frozen forever.

**Schema** — migration first, always. Model `$fillable`/`$casts` are copied from
the migration, never typed from memory. Status/type columns get a CHECK
constraint whose values match the PHP enum **character for character**.

**Services** — all writes in `DB::transaction`. Never call `auth()`, `request()`
or `session()` inside a service; receive explicit params. Dispatch events for
side effects; never send mail/SMS/notifications inline. Inject collaborators via
the constructor.

**Status machines** — every stateful entity declares `VALID_TRANSITIONS` and
throws `InvalidStatusTransitionException` on an illegal move.

**Permissions** — gate at all five points: route middleware, Livewire `mount()`
`authorize()`, service scope check, Blade `@can`, and the list query scope. No
permission means the element is **not rendered**, never greyed out.

**Livewire** — lists use `WithPagination` + `#[Url]` + server-side pagination.
Forms authorize in `mount()`, have `rules()` matching the migration and
`messages()` in Arabic, wrap service calls in try/catch, and put loading states
on every submit button.

**RTL / Arabic** — Arabic is the default. Always use logical Tailwind properties
(`ms/me/ps/pe`, `text-start/end`, `start-*/end-*`, `border-s/e`), never
`ml/mr/pl/pr/left/right`. Numeric inputs get `dir="ltr"`. All user-facing strings
go through `__()`. Font: Cairo.

**Links** — `href="#"` is forbidden. Use `route()`; if a feature isn't built,
don't render the link. Editor-supplied URLs must pass through `safe_url()`.

**Security** — any HTML from an editor renders through `clean_html()`. Never
interpolate a value into an Alpine expression with quotes — use `@js()`.

## Detailed rules

Full versions live in `docs/agent-rules/` — **read the relevant file before
working in that area**, don't rely on the summary above:

| File | Covers |
|---|---|
| `01-migration-first` `16-enums-and-checks` | schema, CHECK constraints, full enum registry |
| `02-multi-tenancy` `10-permissions` | scoping, roles, permission format |
| `03-money-handling` `05-financial-integrity` | piasters, double-entry, invoices, refunds |
| `04-services-and-events` `06-status-transitions` | service pattern, state machines |
| `07-inventory-movements` `13-pos-workflow` | movement types, the 10-step POS flow |
| `08-rtl-arabic` `09-livewire-alpine` | RTL, component structure, Alpine scoping |
| `11-attendance-engine` `18-space-collision` | attendance rules, collision detection |
| `12-pricing-engine` | 10-step resolution order, 13 rule types |
| `14-build-order` `15-integration-checks` | build sequence, pre-commit checklists |
| `17-audit-and-notifications` | audit immutability, notification flow |

Architecture docs: `docs/00-*` … `docs/10-public-website-builder.md`.

## Commands

```bash
php artisan serve
php artisan migrate
php artisan tinker
php artisan website:blueprint export|import --file=…
```

Dev login: `admin@oc-sport.com`

## Working style

- One vertical slice at a time: migration → model → service → Livewire → view → verify.
- Verify before claiming done. Run the command, show the output.
- Keep tool output small: `grep -o` for names, `head -c` (not `head -n`) for
  minified payloads, and never `cat` a file you only need one symbol from.

## Shipping fixes — push without asking

Fixes are pushed, not queued. When a bug, error, crash or regression is fixed
and verified, commit it and `git push origin main` **in the same turn** — do
not ask, do not stop to request approval, do not leave it sitting in the
working tree. This is standing authorisation; asking each time is the thing it
exists to prevent.

Order is fixed and never skipped:

1. **Verify.** Run the test or command and show its output. A fix that has not
   been run does not get pushed.
2. **Check where you are.** `git rev-parse --abbrev-ref HEAD` and `git status`,
   read fresh — never the git snapshot from the top of the session. Another
   session may have switched branches or staged work in this same checkout
   since then.
3. **Commit by explicit path.** `git commit <paths> -m …` naming only the files
   of this fix. Never `-a`, never `git add -A`, never a bare `git commit` that
   sweeps whatever is already in the index. Conventional subject
   (`fix(scope): …`), body explaining the root cause and why this fix and not
   another.
4. **Push to `main`.** Then prove the ref moved — `git rev-parse origin/main`
   must equal the new commit. `git push` prints "Everything up-to-date" and
   exits 0 when it pushed nothing at all, so a clean exit is not evidence.
   Report the SHA only after the ref check.

Committing from a branch that is not `main` is not a failure as long as `main`
is fast-forwarded to the commit and that is what gets pushed — but it has to be
noticed, which is what step 2 is for.

Covers: bug fixes, crash fixes, regressions, and the tests that pin them —
including the incidental fixes found while doing something else.

Still ask first for: new features, migrations that change schema, data
backfills, anything destructive, and any change to a live client's data.

Know what a push costs here. Every client builds from `main`, and
`docker/entrypoint.sh` runs `migrate --force` **and** `db:seed` on every
container start. Pushing is deploying to every tenant at once, with no staging
in between — which is exactly why step 1 is not optional.
