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

17 KiB
Raw Permalink Blame History

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.

.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_cached 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/)

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)

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 HEALTHCHECKs 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().