Files
lux_fiscal/CLAUDE.md
T
lauadminandClaude Opus 5.5 8b3c2b6d63 Bind Nova Poshta API key to cash register (#3)
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>
2026-09-25 13:02:40 +03:00

15 KiB
Raw 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

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 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; client selection is only in services/checkbox/client.get_checkbox_client().