Each cash register stores its own encrypted NP API key. Status polling uses register keys and binds an order to the register whose key sees the TTN as its own (PhoneSender present); ETTN receipts are created from that register. Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register. Closes #3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 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
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.
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=true; client selection is only inservices/checkbox/client.get_checkbox_client().