Files
lauadminandClaude Opus 5.5 2b7a92645a
CI / backend (pull_request) Successful in 3m7s
CI / frontend (pull_request) Failing after 15m55s
Add CRM stub flag, deploy/backup scripts, CI and prod runbook
- CRM_USE_STUB: local runs no longer reach the live CRM. With only the
  Checkbox stub, a stub receipt would still move the real order to PACKED.
  Refused in production, same as CHECKBOX_USE_STUB.
- scripts/deploy.sh: backup, fast-forward main, build, health check and
  code rollback on failure. scripts/backup.sh: pg_dump with verification
  and 14-day rotation (used by cron and deploy.sh).
- Gitea Actions CI: ruff + pytest, oxlint + build.
- DEPLOY.md runbook; CLAUDE.md rules for safe local development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:52:20 +03:00

147 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A web app for manually fiscalizing (пробивать фискальні чеки) orders from an in-house CRM. Orders come from a CRM (own API, still unbuilt — see stub pattern below), delivery status and cash-on-delivery amount come from Nova Poshta, and fiscal receipts are created via the Checkbox API. Cashiers manually trigger receipt creation from a queue of "delivered, not yet fiscalized" orders; nothing fiscal happens automatically. Single cash register today, but the schema is built for several.
The full architecture rationale (why ARQ over Celery, why CRM is behind a `Protocol`, the receipt state machine, the money-units convention, etc.) lives in the project's plan file — read it before making structural changes. If it's not present in this checkout, ask the user for it rather than re-deriving the design from scratch.
Используй краткие ответы, без лишнего кода - только ключевая информация.
## Repository layout
```
backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/app/
frontend/ React 19 + TypeScript + Vite — see frontend/src/
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
docker-compose.prod.yml prod overlay: no host ports, frontend joins the external `web` network (Nginx Proxy Manager)
scripts/ deploy.sh (runs on the prod server), backup.sh (pg_dump + rotation)
.gitea/workflows/ci.yml ruff + pytest, oxlint + build on every PR
DEPLOY.md production runbook — read before touching the server
```
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
## Commands
### Backend (from `backend/`)
Uses a local venv at `backend/.venv`, not a global interpreter.
```bash
.venv/Scripts/pip install -e ".[dev]" # install deps (first time / after pyproject.toml changes)
.venv/Scripts/python -m pytest -q # run all tests
.venv/Scripts/python -m pytest tests/test_security.py -q # single file
.venv/Scripts/python -m pytest tests/test_security.py::TestAccessToken::test_rejects_expired_token -q # single test
.venv/Scripts/python -m ruff check app tests # lint (must be clean — see rules below)
.venv/Scripts/alembic upgrade head # apply migrations (needs POSTGRES_HOST=localhost etc. in .env)
.venv/Scripts/alembic revision -m "..." # new migration — but see "Migrations" below, usually hand-written
.venv/Scripts/uvicorn app.main:app --reload # run API standalone
python -m app.cli bootstrap # create first admin from FIRST_ADMIN_* in .env (idempotent)
python -m app.cli gen-keys # print a fresh SECRET_KEY + ENCRYPTION_KEY
```
Tests require `SECRET_KEY` and `ENCRYPTION_KEY` env vars to be set (any value works for tests; `tests/conftest.py` sets sane defaults via `os.environ.setdefault` before `app.core.config` is imported — order matters, config is read once at import time via `get_settings()`/`lru_cache`).
Tests that touch `Settings()` directly (e.g. `test_config.py`) need `secret_key` and `encryption_key` passed explicitly since `conftest.py`'s env-var defaults don't retroactively apply to already-`lru_cache`d instances.
No live Postgres is required for most tests — the migration-vs-models consistency test (`test_migration_matches_models.py`) statically diffs `alembic/versions/0001_*.py` against `Base.metadata` by regex, without a DB connection. Only actually running `alembic upgrade head` or the app against real data needs Postgres.
### Frontend (from `frontend/`)
```bash
npm install
npm run dev # Vite dev server on :5173, proxies /api/* to http://localhost:8000 (see vite.config.ts)
npm run build # tsc -b && vite build — treat tsc errors as build failures
npm run lint # oxlint (not eslint — see .oxlintrc.json)
npm run preview
```
### Docker (from repo root)
```bash
cp .env.example .env # then fill SECRET_KEY / ENCRYPTION_KEY via `python -m app.cli gen-keys`
docker compose up -d --build
docker compose run --rm api python -m app.cli bootstrap # create first admin
docker compose logs -f api # or: migrate, worker, frontend, postgres, redis
docker compose ps # migrate should show Exited(0) — it's a one-shot job, not a bug
```
`migrate` runs `alembic upgrade head` once and exits; `api`/`worker` `depends_on: migrate: condition: service_completed_successfully`. Don't add migration logic to the `api` container's startup — with multiple replicas that would race.
`worker` runs ARQ (`arq app.worker.WorkerSettings`): cron polls Nova Poshta statuses and Checkbox ETTN receipts every minute, plus the on-demand `create_ettn_receipt` job enqueued by the API. Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the worker doesn't serve HTTP; don't re-enable it without giving it something to check.
## Production and safe local development
Prod runs on `websrv` (`ssh lux-prod`, user `deploy`), `https://asist.ystyle.com.ua`, behind the server's Nginx Proxy Manager. The full runbook (deploy, rollback, backups, restore, CI runner) is in `DEPLOY.md`.
- **The local DB is a copy of prod data.** Local must never reach live systems: `.env` always has `ENVIRONMENT=local`, `CHECKBOX_USE_STUB=true` **and** `CRM_USE_STUB=true`. The Checkbox stub alone is not enough — a stub receipt counts as accepted and `sync_crm_statuses` would move the real CRM order to `PACKED`. Both factories (`get_checkbox_client`, `get_crm_client`) refuse stubs in production. After restoring a prod dump locally, overwrite `cash_registers` keys before starting `api`/`worker` (snippet in `DEPLOY.md`).
- **Workflow:** `feature/*` branch → PR in Gitea (CI green) → merge to `main` → on the server `~/lux_fiscal/scripts/deploy.sh`. `main` is always what prod runs. Never edit tracked files on the server or commit/push to `main` directly.
- **Migrations must be backward-compatible with existing data:** new columns nullable or with `server_default`; drop/rename a column only in a later release after code stopped using it (code rollback does not roll back the schema). Test a new migration locally on a fresh (sanitized) prod backup.
- **Never change prod `ENCRYPTION_KEY`** — cash register secrets in the DB are encrypted with it.
- New integrations with side effects on real systems (CRM, Checkbox, NP, anything that writes) get their own `*_USE_STUB` flag with the same production guard, selected in one factory function.
- Claude has SSH access as `deploy`, but reading/decrypting prod secrets and writing to prod `.env` is left to the user.
## Backend architecture
- **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver.
- **`app/core/`** — cross-cutting, no DB/HTTP knowledge: `config.py` (Pydantic Settings, `.env`-driven), `security.py` (argon2 passwords, JWT access tokens, refresh-token hashing), `crypto.py` (Fernet — for encrypting cash-register secrets in the DB, not yet wired to a model), `logging.py` (structlog, JSON in prod / console locally).
- **`app/db/models/`** — SQLAlchemy models. Every model file must be imported in `app/db/models/__init__.py` or Alembic autogenerate silently won't see its table.
- **`app/services/`** — business logic that touches the DB (`auth.py`, `audit.py`). Routers in `app/api/v1/` stay thin and call into these.
- **`app/api/deps.py`** — `CurrentUser`, `require_admin`/`require_cashier` deps for role gating. Roles: `admin`, `cashier`, `viewer` (`UserRole(enum.StrEnum)` — do NOT use `class X(str, enum.Enum)`, ruff's `UP042` rejects it).
### Auth model (already built, stage 2 of the plan)
- Access JWT (15 min) in memory only, on the frontend. Refresh token (7 days) is a random value; only its SHA-256 hash is stored in `refresh_tokens.token_hash` — never the raw value.
- Refresh rotates on every use (`services/auth.rotate_refresh_token`): the old row gets `revoked_at` + `replaced_by_id`, a new row is inserted. **Presenting an already-revoked refresh token is treated as theft** — it revokes every active token for that user and logs `AuditAction.TOKEN_REUSE_DETECTED`. Don't "simplify" this into a plain revoke-and-reissue without the reuse check.
- `get_current_user` re-reads the user from the DB on every request (not just from JWT claims) so deactivating a user takes effect immediately instead of waiting for the access token to expire.
### Audit log
`app/services/audit.py` — every state-changing endpoint should call `audit.record(...)` in the same DB transaction as the change it's logging, not after `commit()`. It auto-redacts a fixed set of key names (`password`, `*token`, `*secret`, `license_key`, `api_key`) — if you add a new secret-shaped field, add its key to `_REDACTED_KEYS` rather than trusting callers to remember.
### Config gotchas (both bit us for real, in production-shaped values — not hypothetical)
- **`CORS_ORIGINS`** is stored as a raw string (`cors_origins_raw`) and split in a `@property`, not as `list[str]` on the Settings field directly — pydantic-settings tries to JSON-parse complex-typed env vars before validators run, so a plain `a,b` string blows up at startup. Follow this pattern for any future comma-separated env var.
- **DSN building must percent-encode user/password** (`urllib.parse.quote(..., safe="")` in `Settings.database_url`) — `PostgresDsn.build` does not escape special characters itself, and a password containing `,`, `@`, `:`, or `/` silently corrupts the parsed port/host.
- **Alembic's `env.py` builds the async engine directly from `settings.database_url`** (`create_async_engine(...)`) instead of round-tripping through `config.set_main_option("sqlalchemy.url", ...)` / `alembic.ini`. `ConfigParser` treats `%` as an interpolation character, and a percent-encoded password (e.g. `%2C` for a comma) breaks `config.set_main_option` before migrations even start. Don't reintroduce the `alembic.ini`-based URL path.
- Docker `HEALTHCHECK`s must target `127.0.0.1`, not `localhost` — Alpine's `wget`/musl resolves `localhost` to `::1` first and does not fall back to IPv4 on connection refused (unlike `curl`, which tries all resolved addresses). A container can be fully working and still show `unhealthy` if this is wrong.
### Migrations
Hand-written, not autogenerated blindly — `alembic/versions/0001_users_and_audit.py` is deliberately explicit about column nullability, index names, and `ON DELETE` behavior, using `op.f(...)` with the naming convention defined in `app/db/base.py` (`NAMING_CONVENTION`) so constraint names are deterministic and droppable in `downgrade()`. Keep that pattern for new migrations. `tests/test_migration_matches_models.py` will fail if a migration and the models it's supposed to create drift — run it after writing a migration.
### Money and quantity units
Not yet enforced by types anywhere in the current code, but is a hard project convention for when order/receipt models land (plan stages 3+): **all money amounts are integer kopecks, all quantities are integer thousandths** (Checkbox API convention: `1 pcs = 1000`, `2.25 kg = 2250`). Never introduce `float`/`Decimal`-as-currency in new models — use `bigint`.
## Frontend architecture
- **Path alias `@/`** → `frontend/src/` (configured in both `tsconfig.app.json` `paths` and `vite.config.ts` `resolve.alias`). `tsconfig.app.json` needs `"ignoreDeprecations": "6.0"` alongside `baseUrl` — this TS version deprecates bare `baseUrl` otherwise.
- **`verbatimModuleSyntax: true`** — always use `import type { Foo }` for type-only imports, or the build fails.
- **`erasableSyntaxOnly: true`** — no TS `enum`. Use string-literal union types (see `api/types.ts` `UserRole`) or `StrEnum`-equivalents. This mirrors the backend's `UserRole` values by hand; there's no generated client yet, so keep `api/types.ts` in sync with `backend/app/schemas/*.py` manually.
- **No CORS anywhere, by design.** The frontend always calls relative `/api/v1/...` paths. In dev, Vite's `server.proxy` forwards `/api` to `http://localhost:8000`. In Docker/prod, `docker/nginx.frontend.conf` proxies `/api/` to `http://api:8000/api/` inside the compose network. If you ever need to call the API from a different origin, that's a sign something about this setup broke — don't just add CORS headers as a patch.
- **Token handling is split from React on purpose:** `api/tokenStore.ts` is a plain module-level singleton (not a React context) holding the in-memory access token and localStorage-backed refresh token, with a pub-sub `subscribeToTokens`. `api/client.ts` reads/writes it directly to do silent-refresh-and-retry on a 401 without importing React or `AuthContext` — importing `AuthContext` from `client.ts` would create an import cycle (`client → auth-context → client`). `features/auth/AuthContext.tsx` subscribes to the store for React state. Keep this separation when extending auth.
- **`oxlint`, not `eslint`.** Rule names and disable-comment syntax differ (`// oxlint-disable-next-line react/only-export-components`, not `eslint-disable-next-line react-refresh/only-export-components`). Config is `.oxlintrc.json`.
- Routing: `react-router-dom`. `features/auth/ProtectedRoute.tsx` gates authenticated-only routes via `<Outlet />`; `app/routes.tsx` is the single place routes are declared.
- Data fetching: `@tanstack/react-query`, provider set up in `app/App.tsx` with `refetchOnWindowFocus: false` (order/receipt data will be polled explicitly, not refetched on focus).
## Project status (see the plan for the full roadmap)
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has four tabs (`GET /orders?tab=no_receipt|has_receipt|received|refused`, `services/orders.OrderTab`); NP status beats receipt presence: an order whose NP status code is «received» (`NP_RECEIVED_STATUS_CODES`) shows only under «Полученные», a refusal (`NP_REFUSAL_STATUS_CODES`) only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
### Checkbox ETTN receipts
- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them).
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true` together with `CRM_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()` and `services/crm/client.get_crm_client()`.