- 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>
17 KiB
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:
.envalways hasENVIRONMENT=local,CHECKBOX_USE_STUB=trueandCRM_USE_STUB=true. The Checkbox stub alone is not enough — a stub receipt counts as accepted andsync_crm_statuseswould move the real CRM order toPACKED. Both factories (get_checkbox_client,get_crm_client) refuse stubs in production. After restoring a prod dump locally, overwritecash_registerskeys before startingapi/worker(snippet inDEPLOY.md). - Workflow:
feature/*branch → PR in Gitea (CI green) → merge tomain→ on the server~/lux_fiscal/scripts/deploy.sh.mainis always what prod runs. Never edit tracked files on the server or commit/push tomaindirectly. - 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_STUBflag 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.envis 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 inapp/db/models/__init__.pyor Alembic autogenerate silently won't see its table.app/services/— business logic that touches the DB (auth.py,audit.py). Routers inapp/api/v1/stay thin and call into these.app/api/deps.py—CurrentUser,require_admin/require_cashierdeps for role gating. Roles:admin,cashier,viewer(UserRole(enum.StrEnum)— do NOT useclass X(str, enum.Enum), ruff'sUP042rejects 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 getsrevoked_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 logsAuditAction.TOKEN_REUSE_DETECTED. Don't "simplify" this into a plain revoke-and-reissue without the reuse check. get_current_userre-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_ORIGINSis stored as a raw string (cors_origins_raw) and split in a@property, not aslist[str]on the Settings field directly — pydantic-settings tries to JSON-parse complex-typed env vars before validators run, so a plaina,bstring 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="")inSettings.database_url) —PostgresDsn.builddoes not escape special characters itself, and a password containing,,@,:, or/silently corrupts the parsed port/host. - Alembic's
env.pybuilds the async engine directly fromsettings.database_url(create_async_engine(...)) instead of round-tripping throughconfig.set_main_option("sqlalchemy.url", ...)/alembic.ini.ConfigParsertreats%as an interpolation character, and a percent-encoded password (e.g.%2Cfor a comma) breaksconfig.set_main_optionbefore migrations even start. Don't reintroduce thealembic.ini-based URL path. - Docker
HEALTHCHECKs must target127.0.0.1, notlocalhost— Alpine'swget/musl resolveslocalhostto::1first and does not fall back to IPv4 on connection refused (unlikecurl, which tries all resolved addresses). A container can be fully working and still showunhealthyif 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 bothtsconfig.app.jsonpathsandvite.config.tsresolve.alias).tsconfig.app.jsonneeds"ignoreDeprecations": "6.0"alongsidebaseUrl— this TS version deprecates barebaseUrlotherwise. verbatimModuleSyntax: true— always useimport type { Foo }for type-only imports, or the build fails.erasableSyntaxOnly: true— no TSenum. Use string-literal union types (seeapi/types.tsUserRole) orStrEnum-equivalents. This mirrors the backend'sUserRolevalues by hand; there's no generated client yet, so keepapi/types.tsin sync withbackend/app/schemas/*.pymanually.- No CORS anywhere, by design. The frontend always calls relative
/api/v1/...paths. In dev, Vite'sserver.proxyforwards/apitohttp://localhost:8000. In Docker/prod,docker/nginx.frontend.confproxies/api/tohttp://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.tsis a plain module-level singleton (not a React context) holding the in-memory access token and localStorage-backed refresh token, with a pub-subsubscribeToTokens.api/client.tsreads/writes it directly to do silent-refresh-and-retry on a 401 without importing React orAuthContext— importingAuthContextfromclient.tswould create an import cycle (client → auth-context → client).features/auth/AuthContext.tsxsubscribes to the store for React state. Keep this separation when extending auth. oxlint, noteslint. Rule names and disable-comment syntax differ (// oxlint-disable-next-line react/only-export-components, noteslint-disable-next-line react-refresh/only-export-components). Config is.oxlintrc.json.- Routing:
react-router-dom.features/auth/ProtectedRoute.tsxgates authenticated-only routes via<Outlet />;app/routes.tsxis the single place routes are declared. - Data fetching:
@tanstack/react-query, provider set up inapp/App.tsxwithrefetchOnWindowFocus: 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 plainDISCOUNT(«Знижка»), notPRE_PAYMENT— the live API rejectsPRE_PAYMENTon ETTN with 400third_party.generic. The live API also returns statuses lowercase (EttnOutupper-cases them). - Two-phase create:
POST /receiptswritesReceipt(pending)+ audit and commits, then enqueuescreate_ettn_receipt; the worker calls Checkbox. A timeout leaves the rowpendingwitherrorset — the retry first looks the TTN up viafind_ettninstead 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_atis set on request and reset to NULL when a receipt endsfailed/cancelled(order goes back to the queue). - Once Checkbox accepts the receipt, the order is moved to
PACKEDin the CRM (services/receipts.sync_crm_statuses, marked byreceipts.crm_status_set_at; runs right after creation and is retried by cron). The live exoCRMSetStatusdiffers from its docs: params must be{"Orders": [id], "Status": ...}(the documented{"ID": ...}returns "Undefined order list."), and the reply has nostatus: OK— success is{"<id>": {"Status": "Success"}}. - Nova Poshta's rate limit comes back through Checkbox as a 4xx with
code=third_party.genericand «To many requests» /20000401501, not as a 429.http_client._transient_errormaps it toCheckboxRateLimitedError: the receipt stayspendingand the worker retries witharq.Retry. The client also sends requests one at a time with aCHECKBOX_MIN_REQUEST_INTERVAL_MSpause, 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_statusespolls 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 containsPhoneSender— 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_KEYenv is only read by migration 0008. - ETTN does not work on a Checkbox test cash register. Locally use
CHECKBOX_USE_STUB=truetogether withCRM_USE_STUB=true; client selection is only inservices/checkbox/client.get_checkbox_client()andservices/crm/client.get_crm_client().