# CLAUDE.md — ERP This file is read automatically by Claude Code at the start of every session in this project. It exists so Claude never has to guess at conventions, architecture, or the things it must not touch. It is the authoritative engineering instruction set for this repository — when it conflicts with a stale assumption elsewhere, this file (kept current) and the actual code win. ## 1. Project identity This is a custom-built PHP school ERP designed as a **multi-school / multi-tenant platform**, not a single-school product. The architecture (`erp/schools//` for tenant-specific code, `erp/modules/` for shared code) already supports multiple tenants running on one codebase and one deployment. **Do not describe this project as being "for" one named school**, in documentation, comments, or generated output. A school may exist as a **configured production tenant** — that is configuration data, not the identity of the product. Where an example needs a school, use neutral language: "a tenant", "the current production tenant", "School A", "an example school". There is one developer/maintainer on this project. There is no team to catch a mistake before it reaches production — be conservative, and prefer asking or flagging over guessing on anything risky or ambiguous. ## 2. Multi-tenant architecture ``` erp/ ├── modules/ # shared PHP helpers — used by EVERY tenant, must stay tenant-agnostic └── schools/ └── / # one folder per tenant ├── config.php # this tenant's DB connection + school_info/branding ├── admin/ # desktop admin panel ├── app/ # mobile app-shell portals ├── payment_gateway/ └── automated/ # cron/, webhook/, api/, google_sync/ ``` - Shared logic (auth helpers, `sql()`/`config()`/`get_storage_path()`, payment/payout abstractions, etc.) lives in `erp/modules/` and must never hardcode a school-specific value — a prior audit found and fixed exactly this (`security: stop hardcoding SHPS identity in shared, tenant-agnostic modules`). Treat that as a pattern to watch for, not a closed issue. - Tenant-specific values (name, logo, address, affiliation number, social links) come from `$school_info`, built from `config('school', ...)` / `config('branding', ...)` in `erp/schools//config.php` — never hardcode a tenant's name/branding into shared code or into a new module. - Only one tenant folder exists in this repository today. That does not make single-tenant assumptions safe to bake into new shared code. ## 3. Technology philosophy **PHP is the core backend/application language of this project** — most business logic, routing, and rendering is plain PHP with no external MVC framework (a few libraries like PHPMailer and TCPDF are vendored directly under `erp/assets/`, not pulled in via Composer). That does not mean every future task must be solved in PHP. Choose technology pragmatically: - Backend/business logic → PHP, following existing patterns. - Database/schema → SQL, through the project's parameterized helpers. - Browser interaction → vanilla JavaScript (see `erp/assets/app/portal.js`) — no framework has been introduced, and none should be without cause. - CI → GitHub Actions (YAML). - Load testing → k6 (`docs/load-testing/`). - Utility/one-off scripts → whichever language is safest and simplest for the job (Bash, Python, PowerShell on this Windows dev environment — see the platform note in the harness's own system context — are all fine for a scratch/investigation script that never ships as part of the application). Rules of thumb: - Do not introduce a new language or framework "because it's better" — justify it from the actual problem. - Do not force a PHP-only solution onto something PHP is a poor fit for. - Prefer the simplest technology that fits the task and the existing architecture over a fashionable alternative. - Inspect how a similar problem was already solved in this codebase before introducing a new pattern. ## 4. Repository structure ``` erp/ ├── config.php # the ONE config entry point, zero secrets — loads config.local.php ├── config.local.php # real DB credentials + encryption key — gitignored, never committed ├── storage/ # production storage — gitignored except its own .htaccess ├── assets/ │ ├── PHPMailer/ # vendored (not Composer) │ ├── tcpdf/ # vendored (not Composer) │ ├── fonts/ # vendored fonts (see THIRD_PARTY_NOTICES.md) │ └── app/ # shared mobile-portal design system (portal.css/js) ├── modules/ # shared PHP helpers, auto-loaded by base.php (scandir, alphabetical) ├── schools// # per-tenant application code └── url_shortner/ # standalone internal tool, own database (shpsramp_url) docs/ # see the doc index in README.md ``` Production keeps two things inside `erp/` that are **never in this repository**: `erp/config.local.php` (DB credentials + encryption key) and `erp/storage/` (uploads/generated files). Both are gitignored, so no deploy can create or overwrite them; `erp/storage/.htaccess` is the one deliberate exception so the storage tree's hardening ships with the code. See section 8 and `docs/storage.md`. ## 5. Backend conventions - Database access goes through the project's own parameterized helpers — `sql()`, `sql_fetch()`, `sql_fetch_all()`. **Never build raw string-concatenated SQL.** (The one standalone exception, `erp/url_shortner/`, has its own separate database and used raw mysqli historically — it was migrated to parameterized `mysqli_prepare()` calls directly, not the shared `sql()` helpers, since those are wired to a different connection global. New code there should follow that same parameterized pattern.) - `erp/modules/base.php` auto-loads every file in `erp/modules/` via `scandir()` in alphabetical order — a module that runs a query or depends on another module at *load time* (not inside a function) can break depending on filename ordering. Prefer defining logic inside functions, called later, over top-level executable code in a module file. - **Current time always comes from `base.php`'s canonical values — never from a fresh `date()` call.** `base.php` captures the request's time once, under `Asia/Kolkata`, and exposes it as: - `$time` — `d-m-Y h:i:s A` (exactly 22 characters). **This is the ERP's standard timestamp format and the default choice.** Use it for user-facing display *and* for application-managed timestamps stored in `varchar` columns. - `$time5` — `Y-m-d H:i:s`, `$today` — `Y-m-d`, plus `$time2`/`$time3`/ `$time4` for shorter display forms. Rules, in order: 1. **Never call `date()` again to get "now".** Reuse the canonical value, so every row written by one request carries the same instant and timestamps cannot drift mid-request. Inside a function that cannot rely on the global, read `$GLOBALS['time']` (or `$time5`) with a same-format fallback — see `activity_now()` in `erp/modules/activity_log.php` and `staff_attendance_now()` in `erp/modules/staff_attendance.php` for the established pattern. 2. **`$time` is the standard.** `activity_log.al_at` is `VARCHAR(22)` sized precisely for it; `attendance_staff`'s `as_in_datetime`, `as_out_datetime`, `as_created_at` and `as_updated_at` are `varchar(100)` and store it. 3. **A real `DATETIME`/`DATE` column cannot hold `$time`** — MySQL rejects `d-m-Y h:i:s A` and stores a zero date. Those columns take `$time5` (still the canonical value, just the representation the column accepts). `attendance_staff_correction.asc_*`, `cron_job_log.cjl_started_at`, `admin_action_audit.aaa_created_at` and similar are `DATETIME` and stay on `$time5`. **Check the column type before choosing.** 4. **`NOW()` stays** where it is a database-side expression — a `WHERE ... > NOW() - INTERVAL` comparison, or a column `DEFAULT`. Do not replace those with a PHP value. 5. **Only "now" is standardised.** Business dates keep their own values: dates of birth, selected attendance dates, fee due dates, academic session dates, report dates, and timestamps supplied by an external provider (a payment gateway's event time) are data, not the current time. Never overwrite them with `$time`. 6. A `varchar` column holding `d-m-Y h:i:s A` does not sort or range-compare correctly in SQL. Convert with `STR_TO_DATE(col, '%d-%m-%Y %h:%i:%s %p')` before any `ORDER BY`, `BETWEEN` or `<`/`>` on such a column. In PHP, `strtotime()` parses the format correctly, including AM/PM, midnight and noon. 7. **When in doubt about an existing column**, check its type and how it is already queried before assuming either representation. - No `#` prefix on any UI-facing identifier. ## 5a. Comment policy — write none **This codebase contains no comments, and none may be added.** As of 2026-08-30 every comment was deliberately stripped from all project code (PHP, JavaScript, CSS) — roughly 3,900 of them — and the count is now zero. This is the maintainer's explicit standing decision, not an oversight to be corrected. Rules: - Do not add `//`, `#`, `/* */`, `/** */` or `` comments to any project file, for any reason — not a "why", not a security note, not a TODO, not a heading or section banner, not a docblock, and not a one-line explanation of something subtle. There is no exception category. - Do not reintroduce a comment that a previous pass removed. - If code needs explanation, express it in the code: a clear function name, a named intermediate variable, a small extracted function. - Where something genuinely cannot be expressed in code (a schema change, a licensing constraint, a known limitation, an operational caveat), put it in `docs/`, in the commit message, or in the pull request — never in the source file. - Vendored third-party code under `erp/assets/` (tcpdf, PHPMailer, fonts) is the sole exception: it is upstream code, its comments and copyright/licence headers must be left exactly as shipped, and it must never be comment-stripped. Several of those files carry LGPL notices the licence requires retaining. This applies to new files as much as edits to existing ones. ## 6. Frontend conventions - Admin list views: DataTables.js (CDN-loaded — see `THIRD_PARTY_NOTICES.md`). - PDF generation: TCPDF (`erp/assets/tcpdf/`). - The mobile app-shell portals (`erp/schools//app/`) are a **separate frontend** from the desktop admin panel, sharing one design system (`erp/assets/app/portal.css` + `portal.js`). Full token/component reference: `docs/frontend.md`. Do not invent a new one-off `