feat: add tests for import polling, login page, money utilities, and query keys

- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses.
- Created tests for the login page, validating user interactions and error handling.
- Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values.
- Developed tests for query keys, ensuring proper scoping and invalidation roots.

chore: setup testing environment and configuration

- Added setup file for testing with Vitest and Testing Library.
- Configured TypeScript and Vite for the frontend project.
- Created Docker and Nginx configurations for local development and production environments.
- Established a PostgreSQL initialization script for setting up application roles.

docs: add README and environment configuration for infra

- Included README for the infra directory, detailing local development setup and service descriptions.
- Created an example environment configuration file for local development with placeholders for sensitive information.
This commit is contained in:
k1nq
2026-09-14 09:26:33 +05:00
parent d8b938adfb
commit d582f15681
164 changed files with 25553 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
.git
.github
.kilo
.serena
.env
.env.*
**/.env
**/.env.*
**/.venv
**/__pycache__
**/.pytest_cache
**/.ruff_cache
**/.mypy_cache
**/node_modules
frontend/dist
private-fixtures
**/private-fixtures
object-data
minio-data
exports
generated-exports
playwright-report
test-results
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_style = space
indent_size = 4
[{*.js,*.jsx,*.json,*.ts,*.tsx,*.yml,*.yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+8
View File
@@ -0,0 +1,8 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.pdf binary
*.xlsx binary
+150
View File
@@ -0,0 +1,150 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Jobs activate only when their locked dependency manifests exist.
detect:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.dirs.outputs.backend }}
frontend: ${{ steps.dirs.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- id: dirs
run: |
echo "backend=$([ -f backend/uv.lock ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "frontend=$([ -f frontend/pnpm-lock.yaml ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
backend:
name: Backend (lint / type / test / build)
needs: detect
if: needs.detect.outputs.backend == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: finance_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 12
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@localhost:5432/finance_test
REDIS_URL: redis://localhost:6379/0
CELERY_BROKER_URL: redis://localhost:6379/1
IMPORTS_KEK_B64: MDAxMTIyMzM0NDU1NjY3Nzg4ODlhYWJiY2NkZGVlZg==
RLS_DATABASE_URL: postgresql+psycopg://finance_app_test:app-test@localhost:5432/finance_test
S3_ENDPOINT_URL: http://localhost:9000
S3_REGION: us-east-1
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_BUCKET_STATEMENTS: finance-statements
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: backend/uv.lock
- name: Install dependencies
run: uv sync --locked
- name: Lint (ruff check)
run: uv run ruff check .
- name: Format (ruff format)
run: uv run ruff format --check .
- name: Type check (mypy)
run: uv run mypy .
- name: Create restricted app role and migrate
run: |
uv run python -c "from sqlalchemy import create_engine,text; e=create_engine('${DATABASE_URL}'); c=e.connect(); c.execute(text(\"CREATE ROLE finance_app_test LOGIN PASSWORD 'app-test' NOSUPERUSER NOBYPASSRLS\")); c.execute(text('GRANT CONNECT ON DATABASE finance_test TO finance_app_test')); c.execute(text('GRANT USAGE ON SCHEMA public TO finance_app_test')); c.execute(text('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO finance_app_test')); c.commit(); c.close(); e.dispose()"
uv run alembic upgrade head
- name: Start MinIO (S3-compatible test storage)
run: |
docker run -d --name minio-test -p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:latest server /data
for i in $(seq 1 30); do
curl -sf http://localhost:9000/minio/health/live && exit 0
sleep 2
done
echo "MinIO did not become healthy" && exit 1
- name: Test (pytest)
run: uv run pytest -ra
- name: Build provider-neutral image
run: docker build -f ../infra/docker/Dockerfile.backend -t finance-api:ci .
frontend:
name: Frontend (lint / type / test / build)
needs: detect
if: needs.detect.outputs.frontend == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Test (vitest, non-watch)
run: pnpm test
- name: Build
run: pnpm build
compose:
name: Compose & nginx validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Prepare env from example (placeholders only)
run: cp infra/.env.example infra/.env
- name: Validate compose file
run: docker compose -f infra/docker-compose.yml config -q
- name: Boot infra services and wait for healthchecks
run: docker compose -f infra/docker-compose.yml up -d --wait --wait-timeout 180 postgres redis minio mailpit
- name: Show service status
run: docker compose -f infra/docker-compose.yml ps
- name: Validate nginx config
run: |
docker run --rm --network none --add-host api:127.0.0.1 \
-v "$PWD/infra/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:1.27-alpine nginx -t
- name: Teardown
if: always()
run: docker compose -f infra/docker-compose.yml down -v --remove-orphans
+29
View File
@@ -174,3 +174,32 @@ cython_debug/
# PyPI configuration file
.pypirc
# Local project tooling
.serena/
# Node / Vite
node_modules/
frontend/node_modules/
frontend/dist/
*.tsbuildinfo
# Local secrets (tracked examples remain allowed)
.env.*
!.env.example
!**/.env.example
# Private financial inputs and generated data
private-fixtures/
fixtures/private/
**/private-fixtures/
object-data/
minio-data/
/exports/
/generated-exports/
*.statement.xlsx
*.statement.xls
*.statement.pdf
# Browser test output
playwright-report/
test-results/
@@ -0,0 +1,277 @@
# План MVP финансового трекера
## Цель
Собрать закрытую hosted beta личного zero-based бюджета. Пользователь по приглашению создаёт личное финансовое пространство, учитывает RUB-счета и кредитки, планирует календарный месяц, импортирует выписки пяти поддержанных банковских контрактов, разбирает неоднозначности, закрывает период и видит детерминированную аналитику.
Критический пользовательский путь:
1. Принять приглашение, выбрать RU/EN и создать личное пространство.
2. Создать дебетовый, наличный или кредитный счёт с начальной позицией.
3. Создать месячный план из ожидаемых доходов и назначений, при необходимости скопировав прошлый месяц.
4. Загрузить выписку, подтвердить привязку к счёту, исправить критические поля, разрешить дубли/переводы и атомарно применить импорт.
5. Категоризировать расходы, распределить фактически поступившие деньги, покрыть обязательные дефициты и закрыть месяц.
6. Проверить счета, бюджет, операции, импорт и четыре отчёта; экспортировать либо удалить свои данные.
## Зафиксированный объём
- Один личный `Financial Space` на пользователя в UI; схема сразу поддерживает membership с ролями `owner` и `member`.
- Полная видимость данных внутри пространства; счёт принадлежит ровно одному пространству.
- RUB only. Все суммы хранятся целым числом копеек, без `float`.
- Счета MVP: дебетовые/текущие, наличные, кредитные.
- Импорт-контракты: Альфа debit XLSX, Альфа credit XLSX, Ozon debit PDF, Ozon credit PDF, Рокетбанк debit PDF.
- Ozon и Рокетбанк: только PDF с извлекаемым текстом, без OCR. Альфа: только XLSX, без XLS.
- Бюджет: календарный месяц, план и обеспечение разделены, ожидаемый доход можно планировать до поступления.
- Категории: плоский список; одна операция имеет не более одной бюджетной категории.
- Два типа категории: месячная категория и постоянный накопительный конверт.
- Отчёты: расходы по категориям; план/факт; доходы/расходы/cash flow; остатки активов и долг по кредиткам.
- Полностью адаптивный RU/EN web для desktop и mobile.
- Масштаб beta: до 100 пользователей, 50 000 подтверждённых операций на пространство, 10 000 строк и 20 МБ на импорт.
## Вне MVP
- AI-категоризация, AI-аналитика и анализ договоров кредиток.
- Семейные приглашения, передача владения, выход из пространства и совместный UI.
- Полный расчёт льготного периода, процентов и тарифов кредиток; кредитка MVP — баланс/долг и резерв.
- Мультивалютность, вклады, кредиты кроме кредиток, инвестиции, цели с суммой/сроком.
- Split одной операции между несколькими категориями и иерархия категорий.
- OCR, сканы, XLS, credit-выписка Рокетбанка, банковские API и автоматическая синхронизация.
- Движок регулярных платежей; вместо него есть явное копирование плана прошлого месяца.
- Публичная регистрация, биллинг и 2FA.
## Доменный язык
При реализации создать корневой `CONTEXT.md` в формате skill `domain-modeling` и перенести туда только следующие краткие определения, без технических деталей.
- **Пользователь**: подтверждённая личность, которая входит в приложение. Не называть финансовым счётом или владельцем операций.
- **Финансовое пространство**: единая граница владения счетами, операциями, бюджетами и справочниками.
- **Участие**: связь пользователя с финансовым пространством и ролью `owner` либо `member`.
- **Финансовый счёт**: место учёта собственных денег либо долга. Карта является банковским инструментом/атрибутом, а не отдельным синонимом счёта.
- **Операция**: пользовательский финансовый факт: расход, доход, внутренний перевод или возврат.
- **Проводка**: изменение остатка одного внутреннего финансового счёта в рамках операции.
- **Внутренний перевод**: одна операция с двумя противоположными проводками по собственным счетам; не доход и не расход.
- **Возврат**: отдельная операция, полностью или частично связанная с исходным расходом; не доход.
- **Импорт выписки**: загруженный документ и процесс его проверки/применения.
- **Попытка разбора**: версионированный результат конкретного parser adapter для одного документа.
- **Исходная запись**: неизменяемый извлечённый банковский факт с происхождением в документе.
- **Получатель**: нормализованное пространство-локальное имя магазина, сервиса или человека с алиасами исходных описаний.
- **Бюджетный период**: календарный месяц в часовом поясе пользователя со статусом open/closed.
- **Общий пул**: фактически доступные, но ещё не распределённые деньги пространства; может быть отрицательным.
- **Плановое назначение**: доля текущих и ожидаемых денег, которую пользователь намерен направить категории.
- **Обеспечение**: фактически поступившие деньги, перемещённые из общего пула в категорию или резерв.
- **Месячная категория**: назначение расходов, остаток которого при закрытии сворачивается в общий пул.
- **Накопительный конверт**: виртуальный обеспеченный резерв без обязательной цели/срока, переносимый между месяцами.
- **Резерв кредитки**: постоянный системный резерв конкретной кредитки для будущего погашения долга.
- **Ожидаемый доход**: плановое поступление с датой, суммой и источником, отдельно сопоставляемое фактическим доходам.
- **Источник дохода**: необязательная классификация происхождения фактического или ожидаемого поступления.
## Архитектура
### Репозиторий и процессы
- Монорепозиторий: `backend/`, `frontend/`, `infra/`, корневые `CONTEXT.md`, `docs/adr/` и setup-документация.
- Backend: FastAPI, Pydantic 2, синхронный SQLAlchemy 2 + psycopg, Alembic, PostgreSQL.
- Worker: Celery + Redis. HTTP-процесс не разбирает файлы; загрузка возвращает import ID, клиент опрашивает активное состояние через TanStack Query и прекращает polling в terminal state.
- Frontend: React + TypeScript + Vite, React Router, TanStack Query, React Hook Form + Zod, i18next; OpenAPI-generated API client.
- Локально: Docker Compose с API, worker, frontend/reverse proxy, PostgreSQL, Redis, MinIO и SMTP test sink.
- Production: provider-neutral OCI images, PostgreSQL, Redis, S3-compatible storage и SMTP; фактическая площадка, база, объекты и backups обязаны находиться в РФ.
- Пакеты фиксируются `uv.lock` и `pnpm-lock.yaml`; секреты не попадают в `VITE_*` и Git.
### Backend-модули
- `identity`: invitations, users, password reset, sessions, locale/timezone.
- `spaces`: financial spaces, memberships, roles, RLS context.
- `ledger`: accounts, operations, postings, transfers, refunds, voiding, balances.
- `budgeting`: periods, plans, expected income, funding, categories, envelopes, credit reserves, close/reopen.
- `imports`: encrypted documents, parser adapters/runs, source records, reconciliation, deduplication, atomic apply/revert.
- `categorization`: payees, aliases, MCC/bank mappings, user rules and suggestion provenance.
- `analytics`: read queries/snapshots for the four fixed reports.
- `exports`: asynchronous ZIP exports and space deletion workflow.
- `audit`: append-only domain/security events without raw financial payloads.
Модули общаются через application services внутри одного процесса/БД. Не создавать микросервисы, event bus или абстрактные AI-provider interfaces в MVP.
### ADR после scaffold
Создать только ADR, которые сохраняют труднообратимый контекст:
1. `0001-financial-space-ownership-and-rls.md`: данные принадлежат пространству; полная видимость; application checks + PostgreSQL RLS.
2. `0002-journal-and-budget-reserves.md`: журнал операций/проводок, отдельные план/обеспечение, переносимые envelopes/credit reserves, не event sourcing и не полная двойная запись.
3. `0003-versioned-statement-imports.md`: неизменяемое происхождение, версионированные parse runs, атомарное применение/отмена и консервативная дедупликация.
4. `0004-provider-neutral-encrypted-data-boundary.md`: размещение в РФ, S3-neutral envelope encryption и отсутствие внешних AI-вызовов.
## Модель данных и инварианты
### Identity и tenancy
- `users`, `invitations`, `sessions`, `financial_spaces`, `memberships`, `audit_events`.
- Регистрация только по одноразовому email invite; пароль хешируется Argon2id. Email нормализован и уникален без учёта регистра.
- Непрозрачный session token хранится в cookie `HttpOnly`, `Secure`, `SameSite=Lax`; в БД хранится только его hash. Ротация после login/reset, revoke one/all sessions, rate limits и CSRF token для изменяющих запросов.
- В MVP invitation создаёт пространство и единственного owner. В БД всегда должен оставаться минимум один owner.
- Все финансовые таблицы имеют `space_id`; составные FK запрещают cross-space ссылки.
- API проверяет membership, затем внутри транзакции выполняет `SET LOCAL app.space_id`; RLS повторно ограничивает запрос. Worker получает IDs, заново проверяет принадлежность и устанавливает тот же RLS context. Bypass role доступна только migrations и audited break-glass tooling.
- Для конкурентно изменяемых budget/operation/import aggregates использовать version column и `409 Conflict`, а для close/apply/revert — row locks и одну DB transaction.
### Ledger
- `accounts`: `cash | debit | credit`, opening date/amount, bank code, masked product identifier, keyed fingerprint, status `active | archived`.
- Остаток не изменяется напрямую: opening position + confirmed, non-voided postings. Asset balance положительный; credit balance отражает долг по единой документированной sign convention.
- `operations`: `expense | income | transfer | refund`; status `confirmed | voided`; positive amount; `occurred_on`; optional `posted_on`; optional category/income source/payee/refund parent; audit/version.
- `postings`: одна для external income/expense/refund, две равные и противоположные для internal transfer. Погашение кредитки — internal transfer и не создаёт второй расход.
- Бюджет и расходная аналитика используют `occurred_on`. Остаток/сверка используют `posted_on`, когда он есть, иначе `occurred_on`. Если выписка содержит одну дату, parser записывает только `occurred_on`, не выдумывая вторую.
- Операция имеет максимум одну категорию. Income source необязателен и отсутствие источника не блокирует close.
- Ошибочная операция voided с причиной; реальный возврат создаётся отдельно. Изменение/void операции закрытого месяца требует каскадного reopen.
- Refund sum по связанной покупке не должна бесконтрольно превышать исходный расход; неоднозначный/избыточный остаток требует ручной классификации.
- Счёт архивируется только с нулевым остатком и сохраняется в истории; удалить можно только никогда не использованный счёт.
### Budget
- `budget_periods`, `plan_assignments`, `expected_incomes`, `expected_income_matches`, `funding_movements`, `period_closures`.
- Плановый остаток = opening projected pool + expected incomes - plan assignments. Он может быть положительным/отрицательным: UI предупреждает, но не блокирует сохранение или close.
- Actual funding выполняется вручную после поступления денег; UI предлагает недообеспеченные назначения. Expected income может быть закрыт несколькими фактическими incomes и показывает amount/date variance.
- Фактический доход сразу увеличивает general pool независимо от наличия income source.
- Debit/cash expense уменьшает funded category/envelope; uncategorized expense уменьшает системный `Unallocated` deficit/general availability и блокирует close.
- Credit expense увеличивает debt. Обеспеченная часть расхода перемещается из category/envelope в постоянный credit reserve; необеспеченная часть остаётся видимым category debt. Uncategorized credit expense остаётся unallocated/unfunded debt.
- Credit payment сначала уменьшает credit reserve, затем general pool; pool может стать отрицательным. Initial credit debt финансируется пользователем вручную.
- Обычная monthly category при close сворачивает положительный или отрицательный остаток в general pool и начинает следующий период с нуля.
- Savings envelope переносит баланс. Его отрицательный баланс блокирует close до покрытия.
- Credit reserve переносится до платежа. Refund на кредитку уменьшает debt; связанный излишек резерва освобождается в текущий pool.
- Refund в том же открытом месяце восстанавливает категорию покупки. Refund после закрытия исходного месяца пополняет текущий general pool и не переписывает closed budget fact.
- Close блокируют: imports без допустимого reconciliation confirmation, unresolved financial fields/dedup/transfer decisions, expenses без category и отрицательные savings envelopes. Plan imbalance, expected-income variance и underfunded credit debt дают warnings, но не блокируют.
- Reopen старого периода показывает impacted periods, атомарно открывает его и все последующие и пересчитывает preliminary pools; повторный close выполняется последовательно.
- `Copy previous month` копирует plan assignments и expected-income templates, сдвигает ожидаемую дату на следующий месяц с clamp к последнему дню, но не копирует факты, matches или funding.
### Categories и локализация
- `categories`: `monthly | savings_envelope`, `system_key` nullable, `custom_name/name_override` nullable, archived flag.
- Standard category отображается через RU/EN translation key. После rename сохраняется override, который больше не переводится. Custom category всегда хранит одно пользовательское имя.
- Использованная category только архивируется; merge и bulk reclassification вне MVP.
- `income_sources` — отдельный плоский пользовательский справочник; неизвестный источник разрешён и в отчёте группируется как unclassified.
- Locale хранится в user profile. API возвращает machine-readable error codes; React и email templates переводят RU/EN. User timezone configurable, default `Europe/Moscow`; формат суммы/даты следует locale.
### Categorization
- `payees`, `payee_aliases`, `categorization_rules`, versioned MCC dictionary и bank-category mappings.
- Parser извлекает raw description, MCC и bank category, но не назначает пользовательскую category.
- Приоритет: explicit operation choice > confirmed payee rule > MCC suggestion > bank category suggestion > uncategorized.
- Payee rule автоматически применяется к будущим операциям пространства и сохраняет provenance; MCC/bank match остаётся предложением до подтверждения.
### Import
- `statement_documents`, `parse_runs`, `source_records`, `source_record_operation_links`, `dedup_candidates`, reconciliation metadata.
- Файл проходит magic/MIME/size check, hash и quotas до сохранения. Не сохранять полный извлечённый текст документа в БД: persist только allowlisted statement metadata/source fields и page/row coordinates.
- Содержимое шифруется до S3 через AES-GCM envelope encryption: случайный DEK на файл, encrypted DEK и nonce metadata в БД, KEK в secret manager. Random object keys не содержат PII. KEK rotation rewraps DEKs.
- XLSX reader: `openpyxl` в `read_only=True`, `data_only=True`, `keep_links=False`, с `defusedxml`, ZIP quotas и явным close.
- PDF reader: `pdfplumber`; table/text settings принадлежат конкретному adapter contract. Worker non-root, без исходящей сети, с CPU/memory/time limits.
- Adapter interface: content detection + parser version + `StatementDraft` с bank/product/account fingerprint, period, currency, balances/control totals и source rows (external ID, dates, signed amount/direction, description, payee hints, MCC, bank category, location, field warnings).
- State machine: uploaded -> queued -> processing -> review_required/ready -> confirmed либо failed; reparse создаёт новый run, старый не изменяет.
- Критические blockers: currency, amount, direction, occurred date, target account, arithmetic mismatch. Описательные uncertainty отправляются в categorization queue.
- Первая account mapping подтверждается пользователем; stable bank fingerprint далее применяется автоматически. Conflict блокирует импорт.
- Reconciliation: при наличии balances/control totals требуется точное равенство. Если формат не даёт полной проверки, только явный `confirmed_without_full_reconciliation` с причиной/audit.
- Dedupe автоматически связывает stable bank ID или единственное полное однозначное совпадение. Повторяющиеся одинаковые кандидаты остаются отдельными до решения; приоритет — не потерять реальную операцию.
- Internal transfer matcher использует amount/currency/time/account candidates; связывает только однозначную пару, остальные подтверждает пользователь.
- Confirm применяет весь run одной DB transaction: создаёт только новые operations/postings, а duplicates получают additional source links. Ни одна строка не влияет на ledger до confirm.
- Reparse создаёт новый run и diff; пользовательские правки не перезаписываются. Apply проходит полную сверку и dedupe заново.
- Revert confirmed import показывает diff, требует cascade reopen affected periods, атомарно удаляет созданные этим import postings/operations и снимает только source links у ранее существовавших duplicates. Файл/audit остаётся до отдельного удаления.
- Source file хранится до explicit deletion. После удаления сохраняются allowlisted source records, checksum и normalized operations. Удаление пространства уничтожает всё по lifecycle ниже.
## UI и API
- Same-origin `/api/v1` REST; OpenAPI schema генерирует frontend client. Mutations принимают idempotency key там, где browser retry может повторить side effect.
- Главная навигация: Accounts, Budget, Operations, Review Queue, Imports, Analytics, Settings/Data.
- Desktop использует data-dense tables для operations/import review; mobile показывает те же действия карточками и пошаговыми dialogs, без обязательного horizontal scroll.
- Operations: server pagination, date/account/type/category/payee/status filters, search, manual income/expense/transfer/refund, void and audit view.
- Budget: month switcher, preliminary/closed badge, planned/funded/spent/available columns, pool, envelopes, card reserves/debt, expected income matching, close/reopen/copy flows.
- Import: upload/progress, adapter/account detection, field warnings, reconciliation summary, duplicate/transfer decisions, atomic confirm/revert/reparse diff.
- Analytics:
- category spend uses purchase occurrence period; linked late refund adjusts original purchase analytics but not closed budget fact;
- cash flow uses actual posting/refund period (`posted_on`, fallback `occurred_on`), excludes internal transfers/opening positions and identifies refund separately;
- plan/fact uses budget-effective `occurred_on` and immutable closure snapshots for closed periods;
- balances/debt use posting timeline and confirmed non-voided postings.
- Любой chart имеет доступную табличную альтернативу; keyboard/focus/error states и contrast соответствуют WCAG AA.
## Безопасность, данные и эксплуатация
- TLS only; private S3 bucket; encrypted PostgreSQL/Redis/backups; secrets from environment/secret manager; structured logs redact filename, description, payee, account identifiers, email and amounts.
- Нет support UI для просмотра финансов. Break-glass использует отдельную роль, обязательную reason, short-lived grant и append-only audit; обычный admin управляет invites/health без содержимого пространств.
- Uploaded files никогда не исполняются. Ограничить decompression, pages, cells, rows, parser time and worker memory; quarantine failed files; одинаковый file hash делает enqueue/apply idempotent.
- Celery tasks idempotent, `acks_late`, prefetch 1, bounded retry/backoff и terminal failure. DB unique constraints защищают от duplicate task side effects.
- Self-service export создаётся worker как short-lived encrypted ZIP с CSV + JSON для accounts, operations, categories, budgets, expected incomes and audit-relevant provenance; download URL одноразовый/короткоживущий.
- Delete space: 30-day cancellation window, пространство немедленно блокируется; затем hard-delete rows/objects и crypto-shred file keys. Backups хранятся 30 дней; disaster restore повторно применяет durable deletion ledger до открытия сервиса.
- Observability provider-neutral: health/readiness, queue depth, parser duration/failure by adapter version, reconciliation outcomes, DB latency and error codes. Метрики/трейсы не содержат финансовый payload.
- До реальных приглашений провести юридическую проверку обработки персональных/финансовых данных и локализации в РФ; задокументировать privacy/retention/incident procedures.
## Последовательность реализации
1. **Scaffold и доменная документация**
- Создать monorepo layout, locked backend/frontend dependencies, lint/type/test commands, Docker Compose и CI.
- Добавить `CONTEXT.md` и четыре ADR выше; дополнить `.gitignore` для private fixtures, object data, secrets и generated exports.
- Поднять PostgreSQL/Redis/MinIO/test SMTP; добавить Alembic naming convention и transactional migrations.
2. **Identity, sessions и tenant boundary**
- Реализовать admin invite, registration, login/logout/reset, server sessions, CSRF, locale/timezone.
- Создавать personal space + owner membership атомарно.
- Включить RLS policies, request/worker transaction context, composite tenant constraints и negative cross-tenant tests.
3. **Ledger vertical slice**
- Реализовать accounts/opening position, operations/postings, sign convention, manual income/expense/transfer/refund, voiding and archive.
- Добавить transactional domain services, optimistic locking and balance queries/snapshots.
- Построить Accounts/Operations UI и property tests для сохранения сумм/переводов/долга.
4. **Budget vertical slice**
- Реализовать periods, plan assignments, expected incomes/matches, manual funding, monthly categories, envelopes and credit reserves.
- Закодировать debit/credit/refund/uncategorized effects, close gates, closure snapshot, cascade reopen and copy-plan behavior.
- Построить Budget UI с предупреждениями plan imbalance/underfunded card и invariant/property tests.
5. **Безопасное import ядро**
- Реализовать upload validation, envelope encryption/S3 lifecycle, Celery state machine, adapter contract, reconciliation, account mapping and versioned parse runs.
- Реализовать conservative dedupe, transfer matching, atomic confirm, reparse diff, revert import and source deletion.
- Добавить synthetic parser inputs в Git и отдельный `PRIVATE_FIXTURES_DIR` test command; сами реальные документы никогда не коммитить.
6. **Пять bank adapter contracts**
- Последовательно добавить Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.
- Для каждого выделить detection signatures, page/sheet parser, control totals and adapter-specific warnings.
- Adapter не считается включённым в beta, пока не проходит минимум два приватных периода своего contract и malformed/duplicate/overlap cases.
7. **Categorization и review queue**
- Добавить standard/custom category localization, income sources, payees/aliases, MCC and bank maps, precedence/provenance.
- Построить unified Review Queue для critical import issues, duplicate/transfer decisions и uncategorized expenses.
- Проверить, что parser не знает пользовательские категории, а confirmed payee rules не пересекают spaces.
8. **Analytics**
- Реализовать четыре read models/queries с правильными occurred/posted/refund semantics и server-side ranges/pagination.
- Добавить charts + accessible tables; закрытые budget facts читать из closure snapshots, balances — из ledger.
- Проверить агрегаты на generated 50k-operation space и reconcile totals с ledger.
9. **Data controls и hardening**
- Реализовать async export, 30-day delete/cancel, object/key purge, backup deletion ledger and restore test.
- Добавить break-glass tooling/audit, redaction, rate limits, parser sandbox limits, key rotation and security headers.
- Завершить responsive RU/EN flows, accessibility and browser E2E.
10. **Staging и закрытая beta**
- Собрать provider-neutral images, one-shot migrations, same-origin reverse proxy, health checks, backup/restore runbook and alerts.
- Сначала прогнать synthetic data и private parser suite; затем legal/privacy gate и ограниченные email invites.
- Включать bank adapters независимо после contract gate; не заявлять отсутствующие форматы.
## Проверка готовности
- Backend: Ruff/format, static typing, pytest unit/integration against real PostgreSQL/Redis/S3-compatible test services, Hypothesis domain properties.
- Database: Alembic upgrade from empty DB, downgrade where safe, constraints/RLS policies, tenant escape tests, concurrent close/apply conflict tests.
- Parsers: synthetic CI tests plus mandatory private E2E command for five contracts; malformed/oversized/encrypted/scanned/wrong-bank files fail with stable diagnostics.
- Frontend: TypeScript, lint, Vitest + Testing Library, i18n key parity, accessibility checks.
- E2E Playwright on desktop and mobile viewport covers invite/login, account setup, plan/funding, manual operations, import review/confirm, categorization, close/reopen, analytics, export and delete cancellation.
- Financial properties: transfer sums zero; no double-counted card payment; balances equal opening + postings; import confirm/revert is idempotent; category/pool/envelope/reserve transitions conserve the defined available-resource equation; closed periods never change without explicit cascade reopen.
- Analytics fixtures verify late refund on two axes, unknown income source, uncategorized expense, posting-vs-occurrence dates and archived entities.
- Performance gate at agreed beta scale; import remains asynchronous and all operation/report lists remain paginated.
- Disaster test restores a backup, reapplies completed deletion ledger and proves purged spaces/keys remain inaccessible.
## Основные риски и gates
- Private fixtures are an external prerequisite: an adapter cannot be declared supported without them, even if synthetic tests pass.
- Bank layout drift must create a new adapter/parser version and review diff, never silently reinterpret historical data.
- RLS context leakage through pooled connections/tasks is a critical test target; use transaction-local settings and fail closed when context is absent.
- Parsing untrusted PDF/XLSX is isolated and resource-limited; library success alone is not a security boundary.
- Envelope encryption protects object storage but is not zero-knowledge: compromised running API/worker + KEK can decrypt files; break-glass, secret isolation and audit remain necessary.
- Russian data-location and consent requirements are release gates requiring qualified legal review, not assumptions encoded only in infrastructure.
Неразрешённых продуктовых решений для MVP не осталось. Репозиторий пуст, поэтому migration path для существующих данных не требуется.
+23
View File
@@ -0,0 +1,23 @@
# Domain Context
- **Пользователь (User):** подтверждённая личность, которая входит в приложение.
- **Финансовое пространство (Financial Space):** единая граница владения счетами, операциями, бюджетами и справочниками.
- **Участие (Membership):** связь пользователя с финансовым пространством и ролью `owner` либо `member`.
- **Финансовый счёт (Financial Account):** место учёта собственных денег либо долга; карта является банковским инструментом, а не счётом.
- **Операция (Operation):** пользовательский финансовый факт: расход, доход, внутренний перевод или возврат.
- **Проводка (Posting):** изменение остатка одного внутреннего финансового счёта в рамках операции.
- **Внутренний перевод (Internal Transfer):** одна операция с двумя противоположными проводками по собственным счетам; не доход и не расход.
- **Возврат (Refund):** отдельная операция, полностью или частично связанная с исходным расходом; не доход.
- **Импорт выписки (Statement Import):** загруженный документ и процесс его проверки и применения.
- **Попытка разбора (Parse Run):** версионированный результат конкретного parser adapter для одного документа.
- **Исходная запись (Source Record):** неизменяемый извлечённый банковский факт с происхождением в документе.
- **Получатель (Payee):** нормализованное пространство-локальное имя магазина, сервиса или человека с алиасами исходных описаний.
- **Бюджетный период (Budget Period):** календарный месяц в часовом поясе пользователя со статусом `open` либо `closed`.
- **Общий пул (General Pool):** фактически доступные, но ещё не распределённые деньги пространства; может быть отрицательным.
- **Плановое назначение (Plan Assignment):** доля текущих и ожидаемых денег, которую пользователь намерен направить категории.
- **Обеспечение (Funding):** фактически поступившие деньги, перемещённые из общего пула в категорию или резерв.
- **Месячная категория (Monthly Category):** назначение расходов, остаток которого при закрытии сворачивается в общий пул.
- **Накопительный конверт (Savings Envelope):** виртуальный обеспеченный резерв без обязательной цели или срока, переносимый между месяцами.
- **Резерв кредитки (Credit Reserve):** постоянный системный резерв конкретной кредитки для будущего погашения долга.
- **Ожидаемый доход (Expected Income):** плановое поступление с датой, суммой и источником, отдельно сопоставляемое фактическим доходам.
- **Источник дохода (Income Source):** необязательная классификация происхождения фактического или ожидаемого поступления.
+23
View File
@@ -0,0 +1,23 @@
.PHONY: dev down migrate backend-check frontend-check check
dev:
docker compose --env-file infra/.env -f infra/docker-compose.yml up --build
down:
docker compose --env-file infra/.env -f infra/docker-compose.yml down
migrate:
docker compose --env-file infra/.env -f infra/docker-compose.yml run --rm migrate
backend-check:
uv run --project backend ruff check backend
uv run --project backend mypy --config-file backend/pyproject.toml backend/app
uv run --project backend pytest backend
frontend-check:
npx --yes pnpm@9.15.9 --dir frontend lint
npx --yes pnpm@9.15.9 --dir frontend typecheck
npx --yes pnpm@9.15.9 --dir frontend test
npx --yes pnpm@9.15.9 --dir frontend build
check: backend-check frontend-check
+55 -1
View File
@@ -1,2 +1,56 @@
# finance
# Finance Tracker
Closed-beta, zero-based personal budgeting for RUB accounts and credit cards.
The application is a modular monolith with a FastAPI API and Celery worker,
a React web client, PostgreSQL, Redis, S3-compatible object storage, and SMTP.
## Prerequisites
- Docker with Compose
- `uv` for backend development
- Node.js and Corepack (`corepack enable`) for frontend development
## Local stack
```bash
cp infra/.env.example infra/.env
docker compose --env-file infra/.env -f infra/docker-compose.yml up --build
```
The web application is served at `http://localhost:8080`; Mailpit is available
at `http://localhost:8025`, and the MinIO console at `http://localhost:9001`.
Run migrations before the first API start if the compose stack does not do it:
```bash
docker compose --env-file infra/.env -f infra/docker-compose.yml run --rm migrate
```
## Development checks
```bash
uv sync --project backend --all-groups --locked
uv run --project backend ruff check backend
uv run --project backend mypy --config-file backend/pyproject.toml backend/app
uv run --project backend pytest backend
corepack pnpm --dir frontend install --frozen-lockfile
corepack pnpm --dir frontend lint
corepack pnpm --dir frontend typecheck
corepack pnpm --dir frontend test
corepack pnpm --dir frontend build
```
Tests that use real bank documents are deliberately separate and require
`PRIVATE_FIXTURES_DIR`. Never put bank originals under a tracked repository
path. Synthetic parser fixtures are safe for CI.
## Production boundary
Production services must use Russian-hosted PostgreSQL, Redis, object storage,
and backups. TLS, a private object bucket, externally managed secrets, legal
review, and the private parser contract suite are release gates, not defaults
provided by the local compose stack. Browser-visible `VITE_*` variables must
never contain secrets.
See `CONTEXT.md` for domain terminology and `docs/adr/` for architectural
decisions.
+7
View File
@@ -0,0 +1,7 @@
.venv/
var/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+45
View File
@@ -0,0 +1,45 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.config import get_settings
from app.db import models as _models # noqa: F401
from app.db.base import Base
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,85 @@
"""Initial modular-monolith schema and tenant policies."""
from alembic import op
from app.db import models as _models # noqa: F401
from app.db.base import Base
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
TENANT_TABLES = (
"accounts",
"operations",
"postings",
"categories",
"budget_periods",
"plan_assignments",
"expected_incomes",
"expected_income_matches",
"funding_movements",
"period_closures",
"income_sources",
"payees",
"payee_aliases",
"categorization_rules",
"statement_documents",
"parse_runs",
"source_records",
"source_record_links",
"dedup_candidates",
"data_exports",
)
def upgrade() -> None:
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
if bind.dialect.name != "postgresql":
return
for table in TENANT_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(
f"CREATE POLICY tenant_isolation ON {table} "
"USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) "
"WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid)"
)
op.execute(
"""
CREATE FUNCTION prevent_last_owner_removal() RETURNS trigger AS $$
BEGIN
IF OLD.role = 'owner' AND (TG_OP = 'DELETE' OR NEW.role <> 'owner') AND
NOT EXISTS (
SELECT 1 FROM memberships
WHERE space_id = OLD.space_id AND role = 'owner' AND id <> OLD.id
) THEN
RAISE EXCEPTION 'financial space must retain an owner';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER memberships_keep_owner
BEFORE UPDATE OR DELETE ON memberships
FOR EACH ROW EXECUTE FUNCTION prevent_last_owner_removal();
"""
)
op.execute(
"""
CREATE FUNCTION audit_events_append_only() RETURNS trigger AS $$
BEGIN RAISE EXCEPTION 'audit events are append-only'; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_events_no_mutation
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
"""
)
def downgrade() -> None:
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)
if bind.dialect.name == "postgresql":
op.execute("DROP FUNCTION IF EXISTS prevent_last_owner_removal()")
op.execute("DROP FUNCTION IF EXISTS audit_events_append_only()")
View File
View File
+109
View File
@@ -0,0 +1,109 @@
from datetime import date
from fastapi import APIRouter, Query
from app.analytics import service
from app.dependencies import Context, Db
router = APIRouter(prefix="/analytics", tags=["analytics"])
@router.get("/category-spend")
def category_spend(
date_from: date = Query(alias="from"),
date_to: date = Query(alias="to"),
*,
db: Db,
context: Context,
) -> list[dict]:
rows = service.category_spend(
db, space_id=context.space.id, date_from=date_from, date_to=date_to
)
return [
{
"categoryId": row["category_id"],
"name": row["category"] if not row["category"].startswith("category.") else None,
"nameKey": row["category"] if row["category"].startswith("category.") else None,
"amountKopecks": row["amount_kopecks"],
}
for row in rows
]
@router.get("/cash-flow")
def cash_flow(
date_from: date = Query(alias="from"),
date_to: date = Query(alias="to"),
*,
db: Db,
context: Context,
) -> list[dict]:
rows = service.cash_flow(db, space_id=context.space.id, date_from=date_from, date_to=date_to)
months: dict[str, dict[str, int | str]] = {}
for row in rows:
month = row["date"].strftime("%Y-%m")
bucket = months.setdefault(
month,
{
"month": month,
"incomeKopecks": 0,
"expenseKopecks": 0,
"refundsKopecks": 0,
"netKopecks": 0,
},
)
amount = int(row["amount_kopecks"])
key = {
"income": "incomeKopecks",
"expense": "expenseKopecks",
"refund": "refundsKopecks",
}[row["type"]]
bucket[key] = int(bucket[key]) + abs(amount)
bucket["netKopecks"] = int(bucket["netKopecks"]) + amount
return list(months.values())
@router.get("/plan-fact")
def plan_fact(month: str, db: Db, context: Context) -> dict:
from app.budgeting.router import parse_month
year, month_number = parse_month(month)
state = service.plan_fact(db, space_id=context.space.id, year=year, month=month_number)
categories = state.get("monthly_categories", [])
return {
"month": month,
"items": [
{
"categoryId": row["category_id"],
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
"plannedKopecks": row["planned"],
"actualKopecks": row["spent"],
}
for row in categories
],
}
@router.get("/balances")
def balances(db: Db, context: Context) -> dict:
from datetime import UTC, datetime
rows = service.balances(db, space_id=context.space.id)
assets = sum(max(row["balance_kopecks"], 0) for row in rows if row["kind"] != "credit")
liabilities = sum(row["debt_kopecks"] for row in rows)
return {
"asOf": datetime.now(UTC).date(),
"assetsKopecks": assets,
"liabilitiesKopecks": liabilities,
"netKopecks": assets - liabilities,
"items": [
{
"accountId": row["account_id"],
"name": row["account"],
"type": row["kind"],
"balanceKopecks": row["balance_kopecks"],
}
for row in rows
],
}
+105
View File
@@ -0,0 +1,105 @@
import uuid
from collections import defaultdict
from datetime import date
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.budgeting.models import PeriodClosure
from app.budgeting.service import compute_period_state, get_or_create_period
from app.categorization.service import resolve_category_name
from app.ledger.models import Account, Category, Operation
from app.ledger.service import account_balance
def category_spend(
db: Session, *, space_id: uuid.UUID, date_from: date, date_to: date
) -> list[dict]:
expenses = (
db.query(Operation)
.filter(
Operation.space_id == space_id,
Operation.status == "confirmed",
Operation.type == "expense",
Operation.occurred_on >= date_from,
Operation.occurred_on <= date_to,
)
.all()
)
totals: dict[uuid.UUID | None, int] = defaultdict(int)
for expense in expenses:
refunded = (
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
.filter(
Operation.space_id == space_id,
Operation.status == "confirmed",
Operation.type == "refund",
Operation.parent_operation_id == expense.id,
)
.scalar()
)
totals[expense.category_id] += expense.amount_kopecks - int(refunded or 0)
categories = {
item.id: resolve_category_name(item)
for item in db.query(Category).filter_by(space_id=space_id).all()
}
return [
{
"category_id": category_id,
"category": (
categories.get(category_id, "category.unnamed")
if category_id is not None
else "category.uncategorized"
),
"amount_kopecks": amount,
}
for category_id, amount in sorted(totals.items(), key=lambda item: item[1], reverse=True)
]
def cash_flow(db: Session, *, space_id: uuid.UUID, date_from: date, date_to: date) -> list[dict]:
operations = (
db.query(Operation)
.filter(Operation.space_id == space_id, Operation.status == "confirmed")
.all()
)
totals: dict[tuple[date, str], int] = defaultdict(int)
for operation in operations:
effective = operation.posted_on or operation.occurred_on
if not date_from <= effective <= date_to or operation.type == "transfer":
continue
signed = (
operation.amount_kopecks
if operation.type in {"income", "refund"}
else -operation.amount_kopecks
)
totals[(effective, operation.type)] += signed
return [
{"date": day, "type": kind, "amount_kopecks": amount}
for (day, kind), amount in sorted(totals.items())
]
def plan_fact(db: Session, *, space_id: uuid.UUID, year: int, month: int) -> dict:
period = get_or_create_period(db, space_id=space_id, year=year, month=month)
if period.status == "closed":
closure = db.query(PeriodClosure).filter_by(period_id=period.id).one_or_none()
if closure is not None:
return closure.snapshot
return compute_period_state(db, space_id=space_id, period=period)
def balances(db: Session, *, space_id: uuid.UUID) -> list[dict]:
result = []
for account in db.query(Account).filter_by(space_id=space_id).order_by(Account.created_at):
balance = account_balance(db, account)
result.append(
{
"account_id": account.id,
"account": account.name,
"kind": account.kind,
"balance_kopecks": balance,
"debt_kopecks": max(-balance, 0) if account.kind == "credit" else 0,
}
)
return result
View File
+25
View File
@@ -0,0 +1,25 @@
import uuid
from typing import Any
from sqlalchemy import JSON, ForeignKey, String, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
class AuditEvent(Base, PkMixin, TimestampMixin):
"""Append-only domain/security event. Never stores raw financial payloads."""
__tablename__ = "audit_events"
__table_args__ = (
{"comment": "Append-only audit trail; updates and deletes are blocked by trigger in PG."},
)
space_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="SET NULL"), nullable=True, index=True
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
detail: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+23
View File
@@ -0,0 +1,23 @@
from typing import Any
from sqlalchemy.orm import Session
from app.audit.models import AuditEvent
def record(
db: Session,
*,
event_type: str,
space_id: Any = None,
user_id: Any = None,
detail: dict[str, Any] | None = None,
) -> None:
db.add(
AuditEvent(
space_id=space_id,
user_id=user_id,
event_type=event_type,
detail=detail,
)
)
View File
+188
View File
@@ -0,0 +1,188 @@
import uuid
from datetime import date, datetime
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
CheckConstraint,
Date,
ForeignKey,
ForeignKeyConstraint,
Integer,
String,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
PERIOD_STATUSES = ("open", "closed")
FUNDING_KINDS = ("fund", "unfund")
class BudgetPeriod(Base, PkMixin, TimestampMixin):
"""Calendar month budget period in the space timezone (month granularity)."""
__tablename__ = "budget_periods"
__table_args__ = (
CheckConstraint("status IN ('open', 'closed')", name="status_valid"),
CheckConstraint("month BETWEEN 1 AND 12", name="month_valid"),
UniqueConstraint("space_id", "year", "month", name="uq_budget_periods_space_ym"),
UniqueConstraint("space_id", "id", name="uq_budget_periods_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
year: Mapped[int] = mapped_column(nullable=False)
month: Mapped[int] = mapped_column(nullable=False)
status: Mapped[str] = mapped_column(String(8), nullable=False, default="open")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class PlanAssignment(Base, PkMixin, TimestampMixin):
__tablename__ = "plan_assignments"
__table_args__ = (
CheckConstraint("planned_kopecks >= 0", name="planned_nonnegative"),
ForeignKeyConstraint(
["space_id", "period_id"],
["budget_periods.space_id", "budget_periods.id"],
ondelete="CASCADE",
name="fk_plan_assignments_period_tenant",
),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
name="fk_plan_assignments_category_tenant",
),
UniqueConstraint("period_id", "category_id", name="uq_plan_assignments_period_category"),
UniqueConstraint("space_id", "id", name="uq_plan_assignments_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
planned_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class ExpectedIncome(Base, PkMixin, TimestampMixin):
__tablename__ = "expected_incomes"
__table_args__ = (
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
ForeignKeyConstraint(
["space_id", "period_id"],
["budget_periods.space_id", "budget_periods.id"],
ondelete="CASCADE",
name="fk_expected_incomes_period_tenant",
),
UniqueConstraint("space_id", "id", name="uq_expected_incomes_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
expected_on: Mapped[date] = mapped_column(Date, nullable=False)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
source_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
note: Mapped[str | None] = mapped_column(String(500), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class ExpectedIncomeMatch(Base, PkMixin, TimestampMixin):
"""Links one actual income operation to one expected income (1:1 per operation)."""
__tablename__ = "expected_income_matches"
__table_args__ = (
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
ForeignKeyConstraint(
["space_id", "expected_income_id"],
["expected_incomes.space_id", "expected_incomes.id"],
ondelete="CASCADE",
name="fk_income_matches_expected_tenant",
),
ForeignKeyConstraint(
["space_id", "operation_id"],
["operations.space_id", "operations.id"],
name="fk_income_matches_operation_tenant",
),
UniqueConstraint("operation_id", name="uq_income_matches_operation"),
UniqueConstraint("space_id", "id", name="uq_income_matches_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
expected_income_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
class FundingMovement(Base, PkMixin, TimestampMixin):
"""Manual money movement between the general pool and a category/reserve."""
__tablename__ = "funding_movements"
__table_args__ = (
CheckConstraint("kind IN ('fund', 'unfund')", name="kind_valid"),
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
ForeignKeyConstraint(
["space_id", "period_id"],
["budget_periods.space_id", "budget_periods.id"],
ondelete="CASCADE",
name="fk_funding_movements_period_tenant",
),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
name="fk_funding_movements_category_tenant",
),
UniqueConstraint("space_id", "id", name="uq_funding_movements_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
kind: Mapped[str] = mapped_column(String(8), nullable=False)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
note: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class PeriodClosure(Base, PkMixin, TimestampMixin):
"""Immutable-at-rest snapshot written when a period closes."""
__tablename__ = "period_closures"
__table_args__ = (
ForeignKeyConstraint(
["space_id", "period_id"],
["budget_periods.space_id", "budget_periods.id"],
ondelete="CASCADE",
name="fk_period_closures_period_tenant",
),
UniqueConstraint("space_id", "id", name="uq_period_closures_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
closed_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
closed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+296
View File
@@ -0,0 +1,296 @@
import uuid
from datetime import date
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.budgeting import service
from app.dependencies import Context, Csrf, Db
router = APIRouter(prefix="/budget", tags=["budget"])
class CategoryBody(BaseModel):
name: str = Field(min_length=1, max_length=200)
kind: Literal["monthly", "savings_envelope"] = "monthly"
class AssignmentItem(BaseModel):
category_id: uuid.UUID
planned_kopecks: int = Field(ge=0)
class AssignmentsBody(BaseModel):
items: list[AssignmentItem]
class FundingBody(BaseModel):
category_id: uuid.UUID
amount_kopecks: int = Field(gt=0)
kind: Literal["fund", "unfund"] = "fund"
note: str | None = Field(default=None, max_length=500)
class ExpectedIncomeBody(BaseModel):
expected_on: date
amount_kopecks: int = Field(gt=0)
source_name: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
def period_for(db: Db, context: Context, year: int, month: int):
return service.get_or_create_period(db, space_id=context.space.id, year=year, month=month)
class MonthBody(BaseModel):
month: str
class PlanBody(MonthBody):
plannedKopecks: int = Field(ge=0)
class CategoryFundingBody(MonthBody):
amountKopecks: int = Field(gt=0)
def parse_month(value: str) -> tuple[int, int]:
try:
year_text, month_text = value.split("-", 1)
year, month = int(year_text), int(month_text)
except (ValueError, AttributeError) as exc:
from app.errors import bad_request
raise bad_request("BUDGET_PERIOD_INVALID", "Month must use YYYY-MM") from exc
service.validate_year_month(year, month)
return year, month
def frontend_summary(state: dict) -> dict:
monthly = state["monthly_categories"]
envelopes = state["envelopes"]
funded = sum(row["funded"] for row in monthly) + sum(row["funded_total"] for row in envelopes)
spent = sum(row["spent"] for row in monthly)
available = sum(row["available"] for row in monthly + envelopes)
return {
"month": f"{state['period']['year']:04d}-{state['period']['month']:02d}",
"status": state["period"]["status"],
"projectedPoolKopecks": state["carry_pool"]
+ state["expected_income_total"]
- state["planned_total"],
"actualPoolKopecks": state["pool"],
"plannedTotalKopecks": state["planned_total"],
"fundedTotalKopecks": funded,
"spentTotalKopecks": spent,
"availableKopecks": available,
"warnings": [{"code": code} for code in state["warnings"]],
"blockers": state["close_blockers"],
"expectedIncomes": [
{
"id": item["id"],
"expectedOn": item["expected_on"],
"amountKopecks": item["amount_kopecks"],
"sourceName": item["source_name"],
"matchedKopecks": item["matched_kopecks"],
"status": "matched"
if item["matched_kopecks"] >= item["amount_kopecks"]
else "partially_matched"
if item["matched_kopecks"]
else "expected",
}
for item in state["expected_incomes"]
],
}
@router.get("/summary")
def summary(month: str, db: Db, context: Context) -> dict:
year, month_number = parse_month(month)
period = period_for(db, context, year, month_number)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.get("/categories")
def categories(month: str, db: Db, context: Context) -> list[dict]:
year, month_number = parse_month(month)
period = period_for(db, context, year, month_number)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
rows = state["monthly_categories"] + [
{
"category_id": row["category_id"],
"name": row["name"],
"planned": 0,
"funded": row["funded_total"],
"spent": row["funded_total"] - row["available"],
"available": row["available"],
}
for row in state["envelopes"]
]
category_by_id = {
str(item.id): item for item in service.list_categories(db, space_id=context.space.id)
}
return [
{
"id": row["category_id"],
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
"kind": category_by_id[row["category_id"]].kind,
"archived": False,
"creditAccountId": category_by_id[row["category_id"]].linked_account_id,
"plannedKopecks": row["planned"],
"fundedKopecks": row["funded"],
"spentKopecks": row["spent"],
"availableKopecks": row["available"],
}
for row in rows
]
@router.post("/categories", status_code=201)
def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -> dict:
category = service.create_category(
db,
space_id=context.space.id,
user=context.user,
kind=body.kind,
name=body.name,
)
return {"id": category.id, "kind": category.kind, "name": body.name}
@router.patch("/categories/{category_id}")
def update_plan(
category_id: uuid.UUID, body: PlanBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.set_assignments(
db,
space_id=context.space.id,
user=context.user,
period=period,
items=[{"category_id": category_id, "planned_kopecks": body.plannedKopecks}],
)
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
@router.post("/categories/{category_id}/fund")
def fund_category(
category_id: uuid.UUID,
body: CategoryFundingBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.fund(
db,
space_id=context.space.id,
user=context.user,
period=period,
category_id=category_id,
amount_kopecks=body.amountKopecks,
kind="fund",
)
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
@router.put("/{year}/{month}/assignments")
def assignments(
year: int, month: int, body: AssignmentsBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
period = period_for(db, context, year, month)
rows = service.set_assignments(
db,
space_id=context.space.id,
user=context.user,
period=period,
items=[item.model_dump() for item in body.items],
)
return {"count": len(rows)}
@router.post("/{year}/{month}/funding", status_code=201)
def fund(year: int, month: int, body: FundingBody, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
movement = service.fund(
db,
space_id=context.space.id,
user=context.user,
period=period,
**body.model_dump(),
)
return {"id": movement.id}
@router.post("/{year}/{month}/expected-incomes", status_code=201)
def expected_income(
year: int,
month: int,
body: ExpectedIncomeBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
period = period_for(db, context, year, month)
income = service.add_expected_income(
db,
space_id=context.space.id,
user=context.user,
period=period,
**body.model_dump(),
)
return {"id": income.id}
@router.post("/{year}/{month}/close")
def close(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
return service.close_period(db, space_id=context.space.id, user=context.user, period=period)
@router.post("/{year}/{month}/reopen")
def reopen(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
affected = service.reopen_period(
db, space_id=context.space.id, user=context.user, period=period
)
return {"affected_period_ids": [item.id for item in affected]}
@router.post("/{year}/{month}/copy-previous")
def copy_previous(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
return service.copy_previous_month(
db, space_id=context.space.id, user=context.user, period=period
)
@router.post("/close")
def close_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.close_period(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.post("/reopen")
def reopen_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.reopen_period(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.post("/copy-previous")
def copy_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.copy_previous_month(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
+868
View File
@@ -0,0 +1,868 @@
import calendar
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.budgeting.models import (
BudgetPeriod,
ExpectedIncome,
ExpectedIncomeMatch,
FundingMovement,
PeriodClosure,
PlanAssignment,
)
from app.categorization.service import resolve_category_name
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import Account, Category, Operation
from app.ledger.service import ensure_period_open_for_date as _ledger_guard # noqa: F401
def validate_year_month(year: int, month: int) -> None:
if not 1 <= month <= 12 or year < 2000:
raise bad_request("BUDGET_PERIOD_INVALID", "Invalid year/month")
def period_bounds(year: int, month: int) -> tuple[date, date]:
start = date(year, month, 1)
if month == 12:
end = date(year + 1, 1, 1)
else:
end = date(year, month + 1, 1)
return start, end
def shift_month(d: date, months: int) -> date:
index = d.year * 12 + (d.month - 1) + months
year, month = divmod(index, 12)
month += 1
return date(year, month, min(d.day, calendar.monthrange(year, month)[1]))
def get_period(db: Session, *, space_id: uuid.UUID, period_id: uuid.UUID) -> BudgetPeriod:
period = db.query(BudgetPeriod).filter_by(id=period_id, space_id=space_id).one_or_none()
if period is None:
raise not_found("BUDGET_PERIOD_NOT_FOUND", "Budget period not found")
return period
def get_or_create_period(
db: Session, *, space_id: uuid.UUID, year: int, month: int
) -> BudgetPeriod:
validate_year_month(year, month)
period = (
db.query(BudgetPeriod).filter_by(space_id=space_id, year=year, month=month).one_or_none()
)
if period is None:
period = BudgetPeriod(space_id=space_id, year=year, month=month, status="open")
db.add(period)
db.flush()
return period
def list_periods(db: Session, *, space_id: uuid.UUID) -> list[BudgetPeriod]:
return (
db.query(BudgetPeriod)
.filter_by(space_id=space_id)
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
.all()
)
def _previous_ym(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
def _closure_snapshot(db: Session, *, space_id: uuid.UUID, year: int, month: int) -> dict | None:
period = (
db.query(BudgetPeriod)
.filter_by(space_id=space_id, year=year, month=month, status="closed")
.one_or_none()
)
if period is None:
return None
closure = (
db.query(PeriodClosure)
.filter_by(period_id=period.id)
.order_by(PeriodClosure.closed_at.desc())
.first()
)
return closure.snapshot if closure else None
def _balance_until(db: Session, account: Account, end: date) -> int:
from app.ledger.models import Posting
total = (
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
.join(Operation, Operation.id == Posting.operation_id)
.filter(
Posting.account_id == account.id,
Operation.status == "confirmed",
func.coalesce(Operation.posted_on, Operation.occurred_on) <= end,
)
.scalar()
)
return account.opening_amount_kopecks + int(total or 0)
def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeriod) -> dict:
"""Deterministic budget state.
Resource model:
- income lands in the general pool;
- categorized debit/cash expenses consume their category's available amount;
- uncategorized debit/cash expenses reduce the pool directly (unallocated deficit);
- credit expenses grow debt; the funded portion of a credit expense moves from
the category into the account's credit reserve (derived, not stored);
- a credit payment is covered by the credit reserve first, the remainder
reduces the pool (which may go negative);
- a refund on a credit card reduces debt and releases any surplus reserve;
- a same-period refund restores the parent purchase's category; a refund on
an uncategorized expense restores the pool;
- monthly categories fold their residual into the pool at close; savings
envelopes and credit reserves persist via all-time/ snapshot carries.
"""
start, end = period_bounds(period.year, period.month)
accounts = db.query(Account).filter_by(space_id=space_id).all()
account_by_id = {a.id: a for a in accounts}
categories = db.query(Category).filter_by(space_id=space_id, archived=False).all()
ops = (
db.query(Operation)
.filter(
Operation.space_id == space_id,
Operation.status == "confirmed",
Operation.occurred_on >= start,
Operation.occurred_on < end,
)
.all()
)
income_total = 0
monthly_consumption: dict[Any, int] = defaultdict(int)
envelope_consumption_total: dict[Any, int] = defaultdict(int)
credit_expense_period: dict[Any, int] = defaultdict(int)
asset_expense_period_uncategorized = 0
portions_by_account: dict[Any, int] = defaultdict(int)
expense_account_by_cat: dict[Any, dict] = defaultdict(lambda: defaultdict(int))
payments_by_account: dict[Any, int] = defaultdict(int)
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
refund_back_uncategorized_period = 0
for op in ops:
if op.type == "income":
income_total += op.amount_kopecks
elif op.type == "expense":
account = account_by_id[op.account_id]
if account.kind == "credit":
credit_expense_period[op.category_id] += op.amount_kopecks
expense_account_by_cat[op.category_id][account.id] += op.amount_kopecks
if op.category_id is None:
if account.kind != "credit":
asset_expense_period_uncategorized += op.amount_kopecks
else:
monthly_consumption[op.category_id] += op.amount_kopecks
elif op.type == "transfer":
counter = (
account_by_id.get(op.counter_account_id)
if op.counter_account_id is not None
else None
)
if counter is not None and counter.kind == "credit":
payments_by_account[counter.id] += op.amount_kopecks
elif op.type == "refund":
parent_account = account_by_id.get(op.account_id)
parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None
if parent_account is not None and parent_account.kind == "credit":
refunds_to_credit_by_account[parent_account.id] += op.amount_kopecks
elif parent is not None and parent.category_id is not None:
same_period = start <= parent.occurred_on < end
if same_period:
monthly_consumption[parent.category_id] -= op.amount_kopecks
else:
refund_back_uncategorized_period += op.amount_kopecks
else:
refund_back_uncategorized_period += op.amount_kopecks
# Funding movements of this period and all-time envelope funding.
funded_period: dict[Any, int] = defaultdict(int)
for kind, sign in (("fund", 1), ("unfund", -1)):
rows = (
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
.filter(
FundingMovement.space_id == space_id,
FundingMovement.period_id == period.id,
FundingMovement.kind == kind,
)
.group_by(FundingMovement.category_id)
.all()
)
for category_id, total in rows:
funded_period[category_id] += sign * int(total or 0)
funding_total = sum(funded_period.values())
funded_all_time: dict[Any, int] = defaultdict(int)
for kind, sign in (("fund", 1), ("unfund", -1)):
rows = (
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
.filter(
FundingMovement.space_id == space_id,
FundingMovement.kind == kind,
)
.group_by(FundingMovement.category_id)
.all()
)
for category_id, total in rows:
funded_all_time[category_id] += sign * int(total or 0)
# All-time envelope consumption up to period end.
for cat in categories:
if cat.kind != "savings_envelope":
continue
consumed = (
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
.filter(
Operation.space_id == space_id,
Operation.status == "confirmed",
Operation.type == "expense",
Operation.category_id == cat.id,
Operation.occurred_on < end,
)
.scalar()
)
restored = 0
refunds = (
db.query(Operation)
.filter(
Operation.space_id == space_id,
Operation.status == "confirmed",
Operation.type == "refund",
Operation.occurred_on < end,
Operation.parent_operation_id.is_not(None),
)
.all()
)
for refund in refunds:
parent = db.get(Operation, refund.parent_operation_id)
if (
parent is not None
and parent.category_id == cat.id
and parent.occurred_on.year == refund.occurred_on.year
and parent.occurred_on.month == refund.occurred_on.month
):
restored += refund.amount_kopecks
envelope_consumption_total[cat.id] += int(consumed or 0) - restored
# Credit funded portions per category (period-scoped derivation).
credit_funded_portion: dict[Any, int] = defaultdict(int)
for category_id, credit_amount in list(credit_expense_period.items()):
if category_id is None:
continue
noncredit_consumption = monthly_consumption[category_id] - credit_amount
available = funded_period[category_id] - noncredit_consumption
portion = min(max(available, 0), credit_amount)
credit_funded_portion[category_id] += portion
if credit_amount:
allocation = expense_account_by_cat[category_id]
remaining = portion
for index, (account_id, amount) in enumerate(allocation.items()):
account_portion = (
remaining if index == len(allocation) - 1 else portion * amount // credit_amount
)
portions_by_account[account_id] += account_portion
remaining -= account_portion
prev_snapshot = _closure_snapshot(
db,
space_id=space_id,
year=_previous_ym(period.year, period.month)[0],
month=_previous_ym(period.year, period.month)[1],
)
carry_pool = int(prev_snapshot.get("carry_pool", 0)) if prev_snapshot else 0
carry_reserves: dict[str, int] = (
{k: int(v) for k, v in prev_snapshot.get("credit_reserves", {}).items()}
if prev_snapshot
else {}
)
# Reserve and debt per credit account.
credit_reserves: dict[str, int] = {}
credit_debt: dict[str, int] = {}
releases_payment_total = 0
surplus_release_total = 0
for account in accounts:
if account.kind != "credit":
continue
key = str(account.id)
reserve = carry_reserves.get(key, 0) + portions_by_account[account.id]
payment = payments_by_account[account.id]
covered = min(reserve, payment)
releases_payment_total += covered
reserve -= covered
reserve -= refunds_to_credit_by_account[account.id]
debt = max(-_balance_until(db, account, end), 0)
surplus = max(0, reserve - debt)
surplus_release_total += surplus
credit_reserves[key] = min(max(reserve, 0), debt)
credit_debt[key] = debt
uncategorized_outflow = asset_expense_period_uncategorized - refund_back_uncategorized_period
payments_total = sum(payments_by_account.values())
pool = (
carry_pool
+ income_total
- uncategorized_outflow
- (payments_total - releases_payment_total)
+ surplus_release_total
- funding_total
)
monthly_rows: list[dict[str, Any]] = []
monthly_residual_total = 0
for cat in categories:
if cat.kind != "monthly":
continue
available = funded_period[cat.id] - monthly_consumption[cat.id]
monthly_residual_total += available
monthly_rows.append(
{
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"planned": 0,
"funded": funded_period[cat.id],
"spent": monthly_consumption[cat.id],
"available": available,
}
)
envelope_rows: list[dict[str, Any]] = []
for cat in categories:
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
continue
available = funded_all_time[cat.id] - envelope_consumption_total[cat.id]
envelope_rows.append(
{
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"funded_total": funded_all_time[cat.id],
"available": available,
}
)
assignments = db.query(PlanAssignment).filter_by(period_id=period.id).all()
planned_by_cat = {a.category_id: a.planned_kopecks for a in assignments}
for row in monthly_rows:
cid = uuid.UUID(str(row["category_id"]))
row["planned"] = planned_by_cat.get(cid, 0)
planned_total = sum(planned_by_cat.values())
expected = db.query(ExpectedIncome).filter_by(period_id=period.id).all()
expected_total = sum(e.amount_kopecks for e in expected)
matched_by_expected: dict[Any, int] = defaultdict(int)
for m in (
db.query(ExpectedIncomeMatch)
.filter(ExpectedIncomeMatch.expected_income_id.in_([e.id for e in expected] or [None]))
.all()
if expected
else []
):
matched_by_expected[m.expected_income_id] += m.amount_kopecks
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
negative_envelopes = [str(row["name"]) for row in envelope_rows if int(row["available"]) < 0]
warnings = []
if planned_total > carry_pool + expected_total:
warnings.append("plan_imbalance")
for key, debt in credit_debt.items():
if debt > credit_reserves.get(key, 0):
warnings.append("underfunded_credit_debt")
break
if uncategorized_count:
warnings.append("uncategorized_expenses")
if negative_envelopes:
warnings.append("negative_envelopes")
return {
"period": {
"id": str(period.id),
"year": period.year,
"month": period.month,
"status": period.status,
},
"pool": pool,
"carry_pool": carry_pool,
"income_total": income_total,
"planned_total": planned_total,
"expected_income_total": expected_total,
"expected_incomes": [
{
"id": str(e.id),
"expected_on": e.expected_on.isoformat(),
"amount_kopecks": e.amount_kopecks,
"source_name": e.source_name,
"matched_kopecks": matched_by_expected.get(e.id, 0),
}
for e in expected
],
"monthly_categories": monthly_rows,
"envelopes": envelope_rows,
"credit_reserves": credit_reserves,
"credit_debt": credit_debt,
"uncategorized_expenses": uncategorized_count,
"warnings": warnings,
"close_blockers": (
[]
if period.status == "closed"
else _close_blockers(
db,
space_id=space_id,
period=period,
uncategorized_count=uncategorized_count,
negative_envelopes=negative_envelopes,
)
),
}
def _close_blockers(
db: Session,
*,
space_id: uuid.UUID,
period: BudgetPeriod,
uncategorized_count: int,
negative_envelopes: list[str],
) -> list[dict]:
from app.imports.models import StatementDocument
blockers = []
unresolved = (
db.query(StatementDocument.id)
.filter(
StatementDocument.space_id == space_id,
StatementDocument.status.in_(
["uploaded", "queued", "processing", "ready", "review_required"]
),
)
.count()
)
if unresolved:
blockers.append({"code": "IMPORTS_UNRESOLVED", "count": unresolved})
if uncategorized_count:
blockers.append({"code": "BUDGET_UNCATEGORIZED_EXPENSES", "count": uncategorized_count})
if negative_envelopes:
blockers.append({"code": "BUDGET_NEGATIVE_ENVELOPE", "categories": negative_envelopes})
return blockers
def require_open(period: BudgetPeriod) -> None:
if period.status != "open":
raise conflict(
"BUDGET_PERIOD_CLOSED", "Budget period is closed", details={"period_id": str(period.id)}
)
def get_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID) -> Category:
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
if category is None:
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
return category
def create_category(
db: Session,
*,
space_id: uuid.UUID,
user: User,
kind: str,
name: str,
) -> Category:
if kind not in ("monthly", "savings_envelope"):
raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind")
category = Category(space_id=space_id, kind=kind, custom_name=name)
db.add(category)
db.flush()
audit.record(
db,
event_type="category.created",
space_id=space_id,
user_id=user.id,
detail={"category_id": str(category.id)},
)
return category
def ensure_credit_reserve_category(
db: Session, *, space_id: uuid.UUID, account: Account
) -> Category:
category = (
db.query(Category)
.filter_by(space_id=space_id, system_key="credit_reserve", linked_account_id=account.id)
.one_or_none()
)
if category is None:
category = Category(
space_id=space_id,
kind="savings_envelope",
system_key="credit_reserve",
linked_account_id=account.id,
)
db.add(category)
db.flush()
return category
def list_categories(
db: Session, *, space_id: uuid.UUID, include_archived: bool = False
) -> list[Category]:
query = db.query(Category).filter_by(space_id=space_id)
if not include_archived:
query = query.filter(Category.archived.is_(False))
return query.order_by(Category.created_at).all()
def archive_category(
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
) -> Category:
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key == "credit_reserve":
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
category.archived = True
audit.record(
db,
event_type="category.archived",
space_id=space_id,
user_id=user.id,
detail={"category_id": str(category.id)},
)
return category
def rename_category(
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str
) -> Category:
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key is not None:
category.name_override = name
else:
category.custom_name = name
audit.record(
db,
event_type="category.renamed",
space_id=space_id,
user_id=user.id,
detail={"category_id": str(category.id)},
)
return category
def set_assignments(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
items: list[dict],
) -> list[PlanAssignment]:
require_open(period)
result = []
for item in items:
category = get_category(db, space_id=space_id, category_id=item["category_id"])
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
planned = item["planned_kopecks"]
if planned < 0:
raise bad_request("BUDGET_PLANNED_NEGATIVE", "Planned amount cannot be negative")
assignment = (
db.query(PlanAssignment)
.filter_by(period_id=period.id, category_id=category.id)
.one_or_none()
)
if assignment is None:
assignment = PlanAssignment(
space_id=space_id,
period_id=period.id,
category_id=category.id,
planned_kopecks=planned,
)
db.add(assignment)
else:
assignment.planned_kopecks = planned
result.append(assignment)
db.flush()
audit.record(
db,
event_type="budget.assignments_set",
space_id=space_id,
user_id=user.id,
detail={"period_id": str(period.id), "count": len(result)},
)
return result
def add_expected_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
expected_on: date,
amount_kopecks: int,
source_name: str | None,
note: str | None = None,
) -> ExpectedIncome:
require_open(period)
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
income = ExpectedIncome(
space_id=space_id,
period_id=period.id,
expected_on=expected_on,
amount_kopecks=amount_kopecks,
source_name=source_name,
note=note,
)
db.add(income)
db.flush()
return income
def match_expected_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
expected_income_id: uuid.UUID,
operation_id: uuid.UUID,
amount_kopecks: int | None = None,
) -> ExpectedIncomeMatch:
expected = (
db.query(ExpectedIncome).filter_by(id=expected_income_id, space_id=space_id).one_or_none()
)
if expected is None:
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
if operation is None or operation.type != "income" or operation.status != "confirmed":
raise not_found("BUDGET_INCOME_OPERATION_NOT_FOUND", "Confirmed income operation not found")
existing = db.query(ExpectedIncomeMatch).filter_by(operation_id=operation.id).one_or_none()
if existing is not None:
raise conflict("BUDGET_INCOME_ALREADY_MATCHED", "Income already matched to expected income")
amount = (
amount_kopecks
if amount_kopecks is not None
else min(operation.amount_kopecks, expected.amount_kopecks)
)
if amount <= 0 or amount > operation.amount_kopecks:
raise bad_request(
"BUDGET_MATCH_AMOUNT_INVALID",
"Match amount must be within the income operation",
)
match = ExpectedIncomeMatch(
space_id=space_id,
expected_income_id=expected.id,
operation_id=operation.id,
amount_kopecks=amount,
)
db.add(match)
db.flush()
return match
def fund(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
category_id: uuid.UUID,
amount_kopecks: int,
kind: str,
note: str | None = None,
) -> FundingMovement:
require_open(period)
if kind not in ("fund", "unfund"):
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
get_category(db, space_id=space_id, category_id=category_id)
movement = FundingMovement(
space_id=space_id,
period_id=period.id,
category_id=category_id,
kind=kind,
amount_kopecks=amount_kopecks,
note=note,
created_by_user_id=user.id,
)
db.add(movement)
db.flush()
return movement
def close_period(db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod) -> dict:
period = (
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
)
require_open(period)
previous_year, previous_month = _previous_ym(period.year, period.month)
earlier_period = (
db.query(BudgetPeriod)
.filter(
BudgetPeriod.space_id == space_id,
(BudgetPeriod.year < period.year)
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month < period.month)),
)
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
.with_for_update()
.first()
)
if earlier_period is not None and (
earlier_period.year != previous_year
or earlier_period.month != previous_month
or earlier_period.status != "closed"
):
raise conflict(
"BUDGET_CLOSE_OUT_OF_ORDER",
"Close the previous calendar month before this period",
details={"year": previous_year, "month": previous_month},
)
state = compute_period_state(db, space_id=space_id, period=period)
if state["close_blockers"]:
raise conflict(
"BUDGET_CLOSE_BLOCKED",
"Period has blocking issues",
details={"blockers": state["close_blockers"]},
)
monthly_residual = sum(row["available"] for row in state["monthly_categories"])
snapshot = {
"period": state["period"],
"closed_at": datetime.now(UTC).isoformat(),
"pool": state["pool"],
"carry_pool": state["pool"] + monthly_residual,
"income_total": state["income_total"],
"planned_total": state["planned_total"],
"monthly_categories": state["monthly_categories"],
"envelopes": state["envelopes"],
"credit_reserves": state["credit_reserves"],
"credit_debt": state["credit_debt"],
"warnings": state["warnings"],
}
closure = PeriodClosure(
space_id=space_id,
period_id=period.id,
snapshot=snapshot,
closed_at=datetime.now(UTC),
closed_by_user_id=user.id,
)
period.status = "closed"
db.add(closure)
db.flush()
audit.record(
db,
event_type="budget.closed",
space_id=space_id,
user_id=user.id,
detail={"period_id": str(period.id)},
)
return snapshot
def reopen_period(
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
) -> list[BudgetPeriod]:
period = (
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
)
if period.status != "closed":
raise conflict("BUDGET_PERIOD_NOT_CLOSED", "Only closed periods can be reopened")
affected = (
db.query(BudgetPeriod)
.filter(
BudgetPeriod.space_id == space_id,
BudgetPeriod.status == "closed",
(BudgetPeriod.year > period.year)
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month >= period.month)),
)
.order_by(BudgetPeriod.year, BudgetPeriod.month)
.all()
)
for p in affected:
p.status = "open"
db.flush()
audit.record(
db,
event_type="budget.reopened",
space_id=space_id,
user_id=user.id,
detail={"period_id": str(period.id), "impacted": [str(p.id) for p in affected]},
)
return affected
def copy_previous_month(
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
) -> dict:
require_open(period)
prev_year, prev_month = _previous_ym(period.year, period.month)
previous = (
db.query(BudgetPeriod)
.filter_by(space_id=space_id, year=prev_year, month=prev_month)
.one_or_none()
)
if previous is None:
raise not_found("BUDGET_PREVIOUS_PERIOD_MISSING", "Previous month period does not exist")
copied_assignments = 0
for assignment in db.query(PlanAssignment).filter_by(period_id=previous.id).all():
category = get_category(db, space_id=space_id, category_id=assignment.category_id)
if category.archived:
continue
existing = (
db.query(PlanAssignment)
.filter_by(period_id=period.id, category_id=assignment.category_id)
.one_or_none()
)
if existing is None:
db.add(
PlanAssignment(
space_id=space_id,
period_id=period.id,
category_id=assignment.category_id,
planned_kopecks=assignment.planned_kopecks,
)
)
else:
existing.planned_kopecks = assignment.planned_kopecks
copied_assignments += 1
copied_expected = 0
for expected in db.query(ExpectedIncome).filter_by(period_id=previous.id).all():
db.add(
ExpectedIncome(
space_id=space_id,
period_id=period.id,
expected_on=shift_month(expected.expected_on, 1),
amount_kopecks=expected.amount_kopecks,
source_name=expected.source_name,
note=expected.note,
)
)
copied_expected += 1
db.flush()
audit.record(
db,
event_type="budget.copied",
space_id=space_id,
user_id=user.id,
detail={
"period_id": str(period.id),
"assignments": copied_assignments,
"expected": copied_expected,
},
)
return {"assignments_copied": copied_assignments, "expected_incomes_copied": copied_expected}
+113
View File
@@ -0,0 +1,113 @@
import uuid
from sqlalchemy import (
Boolean,
ForeignKey,
ForeignKeyConstraint,
Integer,
String,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
class IncomeSource(Base, PkMixin, TimestampMixin):
__tablename__ = "income_sources"
__table_args__ = (
UniqueConstraint("space_id", "name", name="uq_income_sources_space_name"),
UniqueConstraint("space_id", "id", name="uq_income_sources_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class Payee(Base, PkMixin, TimestampMixin):
__tablename__ = "payees"
__table_args__ = (
UniqueConstraint("space_id", "name", name="uq_payees_space_name"),
UniqueConstraint("space_id", "id", name="uq_payees_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class PayeeAlias(Base, PkMixin, TimestampMixin):
__tablename__ = "payee_aliases"
__table_args__ = (
UniqueConstraint("space_id", "alias", name="uq_payee_aliases_space_alias"),
ForeignKeyConstraint(
["space_id", "payee_id"],
["payees.space_id", "payees.id"],
ondelete="CASCADE",
name="fk_payee_aliases_payee_tenant",
),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
payee_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
alias: Mapped[str] = mapped_column(String(300), nullable=False)
class CategorizationRule(Base, PkMixin, TimestampMixin):
"""Confirmed payee rule: auto-applies category to matching future operations."""
__tablename__ = "categorization_rules"
__table_args__ = (
UniqueConstraint("space_id", "payee_id", name="uq_rules_space_payee"),
ForeignKeyConstraint(
["space_id", "payee_id"],
["payees.space_id", "payees.id"],
ondelete="CASCADE",
name="fk_rules_payee_tenant",
),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
name="fk_rules_category_tenant",
),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
payee_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class MccCode(Base):
"""Global versioned MCC dictionary (not space-scoped, outside RLS)."""
__tablename__ = "mcc_codes"
code: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(300), nullable=False)
dictionary_version: Mapped[int] = mapped_column(nullable=False, default=1)
class BankCategoryMapping(Base, PkMixin):
"""Maps a bank's raw category string to an MCC for suggestion purposes."""
__tablename__ = "bank_category_mappings"
__table_args__ = (
UniqueConstraint("bank_code", "bank_category", name="uq_bank_mappings_bank_category"),
)
bank_code: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
bank_category: Mapped[str] = mapped_column(String(200), nullable=False)
mcc_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
+97
View File
@@ -0,0 +1,97 @@
from sqlalchemy.orm import Session
from app.categorization.models import CategorizationRule, IncomeSource, Payee, PayeeAlias
from app.errors import conflict, not_found
from app.ledger.models import Category
def resolve_category_name(category: Category) -> str:
"""Standard categories resolve to a translation key; overrides/customs are literal."""
if category.custom_name is not None:
return category.custom_name
if category.name_override is not None:
return category.name_override
if category.system_key is not None:
return f"category.{category.system_key}"
return "category.unnamed"
def get_income_source(db: Session, *, space_id, income_source_id) -> IncomeSource:
obj = db.query(IncomeSource).filter_by(id=income_source_id, space_id=space_id).one_or_none()
if obj is None:
raise not_found("INCOME_SOURCE_NOT_FOUND", "Income source not found")
return obj
def ensure_income_source(db: Session, *, space_id, name: str) -> IncomeSource:
obj = db.query(IncomeSource).filter_by(space_id=space_id, name=name).one_or_none()
if obj is None:
obj = IncomeSource(space_id=space_id, name=name)
db.add(obj)
db.flush()
return obj
def get_or_create_payee(db: Session, *, space_id, name: str, alias: str | None = None) -> Payee:
payee = db.query(Payee).filter_by(space_id=space_id, name=name).one_or_none()
if payee is None:
payee = Payee(space_id=space_id, name=name)
db.add(payee)
db.flush()
if alias:
add_alias(db, space_id=space_id, payee_id=payee.id, alias=alias)
return payee
def add_alias(db: Session, *, space_id, payee_id, alias: str) -> PayeeAlias:
existing = db.query(PayeeAlias).filter_by(space_id=space_id, alias=alias).one_or_none()
if existing is not None:
if existing.payee_id != payee_id:
raise conflict(
"PAYEE_ALIAS_TAKEN",
"Alias already mapped to another payee",
)
return existing
obj = PayeeAlias(space_id=space_id, payee_id=payee_id, alias=alias)
db.add(obj)
db.flush()
return obj
def find_payee_by_description(db: Session, *, space_id, description: str) -> Payee | None:
if not description:
return None
alias = (
db.query(PayeeAlias)
.filter(PayeeAlias.space_id == space_id, PayeeAlias.alias == description)
.one_or_none()
)
if alias is not None:
return db.get(Payee, alias.payee_id)
payee = db.query(Payee).filter_by(space_id=space_id, name=description).one_or_none()
return payee
def rule_category_for_payee(db: Session, *, space_id, payee_id) -> CategorizationRule | None:
return (
db.query(CategorizationRule).filter_by(space_id=space_id, payee_id=payee_id).one_or_none()
)
def set_payee_rule(db: Session, *, space_id, payee_id, category_id, user_id) -> CategorizationRule:
payee = db.query(Payee).filter_by(id=payee_id, space_id=space_id).one_or_none()
if payee is None:
raise not_found("PAYEE_NOT_FOUND", "Payee not found")
rule = rule_category_for_payee(db, space_id=space_id, payee_id=payee_id)
if rule is None:
rule = CategorizationRule(
space_id=space_id,
payee_id=payee_id,
category_id=category_id,
created_by_user_id=user_id,
)
db.add(rule)
else:
rule.category_id = category_id
db.flush()
return rule
+68
View File
@@ -0,0 +1,68 @@
import base64
import binascii
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
environment: str = "development"
database_url: str = "postgresql+psycopg://fin:fin@localhost:5432/fin"
redis_url: str = "redis://localhost:6379/0"
admin_bootstrap_token: str = ""
imports_kek_b64: str = ""
blob_dir: str = "./var/blobs"
s3_endpoint_url: str = ""
s3_region: str = "us-east-1"
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_bucket_statements: str = "finance-statements"
smtp_host: str = ""
smtp_port: int = 1025
smtp_use_tls: bool = False
smtp_from: str = "no-reply@finance.local"
public_base_url: str = "http://localhost:8080"
session_cookie_name: str = "fin_session"
csrf_cookie_name: str = "csrf_token"
cookie_secure: bool = True
session_ttl_days: int = 14
invite_ttl_days: int = 7
reset_ttl_hours: int = 4
max_upload_bytes: int = 20 * 1024 * 1024
max_xlsx_rows: int = 10_000
max_pdf_pages: int = 200
max_archive_uncompressed_bytes: int = 64 * 1024 * 1024
deletion_window_days: int = 30
celery_task_always_eager: bool = False
celery_broker_url: str = ""
default_locale: str = "ru"
default_timezone: str = "Europe/Moscow"
@property
def broker_url(self) -> str:
return self.celery_broker_url or self.redis_url
@property
def kek(self) -> bytes:
if not self.imports_kek_b64:
raise RuntimeError("IMPORTS_KEK_B64 must be configured (base64-encoded 32-byte KEK)")
try:
raw = base64.b64decode(self.imports_kek_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise RuntimeError("IMPORTS_KEK_B64 is not valid base64") from exc
if len(raw) != 32:
raise RuntimeError("IMPORTS_KEK_B64 must decode to exactly 32 bytes")
return raw
@lru_cache
def get_settings() -> Settings:
return Settings()
View File
+52
View File
@@ -0,0 +1,52 @@
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import DateTime, MetaData, TypeDecorator
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class UTCDateTime(TypeDecorator):
"""Datetimes stored as naive UTC in the DB and exposed tz-aware (UTC) in Python."""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: Any, dialect: Any) -> Any:
if value is not None:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
value = value.astimezone(UTC).replace(tzinfo=None)
return value
def process_result_value(self, value: Any, dialect: Any) -> Any:
if value is not None:
value = value.replace(tzinfo=UTC)
return value
def utcnow() -> datetime:
return datetime.now(UTC)
class PkMixin:
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime(), default=utcnow, onupdate=utcnow, nullable=False
)
+63
View File
@@ -0,0 +1,63 @@
"""Import every mapped class so Alembic sees complete metadata."""
from app.audit.models import AuditEvent
from app.budgeting.models import (
BudgetPeriod,
ExpectedIncome,
ExpectedIncomeMatch,
FundingMovement,
PeriodClosure,
PlanAssignment,
)
from app.categorization.models import (
BankCategoryMapping,
CategorizationRule,
IncomeSource,
MccCode,
Payee,
PayeeAlias,
)
from app.exports.models import DataExport, DeletionLedger
from app.identity.models import Invitation, PasswordReset, Session, User
from app.imports.models import (
DedupCandidate,
ParseRun,
SourceRecord,
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Account, Category, Operation, Posting
from app.spaces.models import FinancialSpace, Membership
__all__ = [
"Account",
"AuditEvent",
"BankCategoryMapping",
"BudgetPeriod",
"Category",
"CategorizationRule",
"DataExport",
"DedupCandidate",
"DeletionLedger",
"ExpectedIncome",
"ExpectedIncomeMatch",
"FinancialSpace",
"FundingMovement",
"IncomeSource",
"Invitation",
"MccCode",
"Membership",
"Operation",
"ParseRun",
"PasswordReset",
"Payee",
"PayeeAlias",
"PeriodClosure",
"PlanAssignment",
"Posting",
"Session",
"SourceRecord",
"SourceRecordLink",
"StatementDocument",
"User",
]
+71
View File
@@ -0,0 +1,71 @@
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def get_engine() -> Engine:
global _engine
if _engine is None:
_engine = create_engine(get_settings().database_url, pool_pre_ping=True)
if _engine.dialect.name == "sqlite":
@event.listens_for(_engine, "connect")
def _sqlite_pragmas(dbapi_connection: Any, _record: Any) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return _engine
def get_session_factory() -> sessionmaker[Session]:
global _session_factory
if _session_factory is None:
_session_factory = sessionmaker(bind=get_engine(), expire_on_commit=False)
return _session_factory
def get_db() -> Iterator[Session]:
session = get_session_factory()()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def set_space_context(session: Session, space_id: Any) -> None:
"""Transaction-local RLS context. No-op on SQLite (tests); fail-closed on PostgreSQL."""
if session.get_bind().dialect.name == "postgresql":
session.execute(
text("select set_config('app.space_id', :sid, true)"),
{"sid": str(space_id)},
)
@contextmanager
def session_scope(space_id: Any = None) -> Iterator[Session]:
"""Transactional scope for workers/endpoints that own the transaction lifecycle."""
session = get_session_factory()()
try:
if space_id is not None:
set_space_context(session, space_id)
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
+67
View File
@@ -0,0 +1,67 @@
from dataclasses import dataclass
from typing import Annotated
from fastapi import Cookie, Depends, Header
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.session import get_db, set_space_context
from app.errors import forbidden, unauthorized
from app.identity.models import Session as UserSession
from app.identity.models import User
from app.identity.service import get_active_session
from app.security import constant_time_equals
from app.spaces.models import FinancialSpace, Membership
from app.spaces.service import list_spaces_for_user
Db = Annotated[Session, Depends(get_db)]
@dataclass(frozen=True)
class RequestContext:
user: User
session: UserSession
space: FinancialSpace
membership: Membership
def current_session(
db: Db,
session_token: Annotated[str | None, Cookie(alias=get_settings().session_cookie_name)] = None,
) -> tuple[User, UserSession]:
if not session_token:
raise unauthorized("IDENTITY_SESSION_MISSING", "Authentication required")
session = get_active_session(db, token=session_token)
user = db.get(User, session.user_id)
if user is None or user.status != "active":
raise unauthorized("IDENTITY_USER_INACTIVE", "Authentication required")
return user, session
CurrentSession = Annotated[tuple[User, UserSession], Depends(current_session)]
def current_context(db: Db, auth: CurrentSession) -> RequestContext:
user, session = auth
spaces = list_spaces_for_user(db, user=user)
if not spaces:
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
space, membership = spaces[0]
if space.status != "active":
raise forbidden("SPACE_BLOCKED", "Financial space is unavailable")
set_space_context(db, space.id)
return RequestContext(user=user, session=session, space=space, membership=membership)
Context = Annotated[RequestContext, Depends(current_context)]
def require_csrf(
context: Context,
csrf_header: Annotated[str | None, Header(alias="X-CSRF-Token")] = None,
) -> None:
if not csrf_header or not constant_time_equals(context.session.csrf_token, csrf_header):
raise forbidden("IDENTITY_CSRF_INVALID", "CSRF token is missing or invalid")
Csrf = Annotated[None, Depends(require_csrf)]
+48
View File
@@ -0,0 +1,48 @@
from typing import Any
class AppError(Exception):
"""Domain error rendered as a machine-readable JSON error payload."""
status_code: int = 400
code: str = "APP_ERROR"
def __init__(
self,
message: str,
*,
code: str | None = None,
status_code: int | None = None,
details: Any = None,
) -> None:
super().__init__(message)
self.message = message
if code is not None:
self.code = code
if status_code is not None:
self.status_code = status_code
self.details = details
def bad_request(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=400, details=details)
def unauthorized(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=401, details=details)
def forbidden(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=403, details=details)
def not_found(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=404, details=details)
def conflict(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=409, details=details)
def unprocessable(code: str, message: str, details: Any = None) -> AppError:
return AppError(message, code=code, status_code=422, details=details)
View File
+36
View File
@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, LargeBinary, String, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
class DataExport(Base, PkMixin, TimestampMixin):
__tablename__ = "data_exports"
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
requested_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="queued")
object_key: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True)
dek_wrapped: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
dek_wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
content_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
class DeletionLedger(Base, PkMixin, TimestampMixin):
"""Durable deletion marker reapplied after disaster restore."""
__tablename__ = "deletion_ledger"
space_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, unique=True, index=True)
scheduled_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
+60
View File
@@ -0,0 +1,60 @@
import uuid
from fastapi import APIRouter, Response
from app.dependencies import Context, Csrf, Db
from app.exports import service
from app.exports.tasks import enqueue_export
router = APIRouter(prefix="/exports", tags=["data"])
@router.post("", status_code=202)
def request_export(db: Db, context: Context, _csrf: Csrf) -> dict:
job = service.request_export(db, space_id=context.space.id, user_id=context.user.id)
db.commit()
try:
enqueue_export(job.id, context.space.id)
except Exception as exc:
from app.db.session import set_space_context
from app.errors import AppError
set_space_context(db, context.space.id)
job = service.get_export(db, space_id=context.space.id, export_id=job.id)
job.status = "failed"
job.error_code = "EXPORT_QUEUE_FAILED"
db.commit()
raise AppError(
"Export queue is unavailable",
code="EXPORT_QUEUE_FAILED",
status_code=503,
) from exc
return {
"id": job.id,
"status": job.status,
"downloadUrl": None,
"createdAt": job.created_at,
}
@router.get("/{export_id}")
def export_status(export_id: uuid.UUID, db: Db, context: Context) -> dict:
job = service.get_export(db, space_id=context.space.id, export_id=export_id)
return {
"id": job.id,
"status": job.status,
"downloadUrl": f"/api/v1/exports/{job.id}/download" if job.status == "ready" else None,
"createdAt": job.created_at,
"expiresAt": job.expires_at,
"errorCode": job.error_code,
}
@router.get("/{export_id}/download")
def download(export_id: uuid.UUID, db: Db, context: Context) -> Response:
raw = service.consume_export(db, space_id=context.space.id, export_id=export_id)
return Response(
raw,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="finance-export.zip"'},
)
+194
View File
@@ -0,0 +1,194 @@
import csv
import io
import json
import uuid
import zipfile
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from app.audit.models import AuditEvent
from app.budgeting.models import BudgetPeriod, ExpectedIncome, FundingMovement, PlanAssignment
from app.config import get_settings
from app.errors import conflict, not_found
from app.exports.models import DataExport, DeletionLedger
from app.imports.blob import get_blob_store, new_object_key
from app.imports.crypto import decrypt_file, encrypt_file
from app.imports.models import StatementDocument
from app.ledger.models import Account, Category, Operation
from app.spaces.models import FinancialSpace
def request_export(db: Session, *, space_id: uuid.UUID, user_id: uuid.UUID) -> DataExport:
job = DataExport(space_id=space_id, requested_by_user_id=user_id, status="queued")
db.add(job)
db.flush()
return job
def get_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> DataExport:
job = db.query(DataExport).filter_by(id=export_id, space_id=space_id).one_or_none()
if job is None:
raise not_found("EXPORT_NOT_FOUND", "Data export not found")
return job
def _json_rows(rows: Sequence[object], fields: tuple[str, ...]) -> list[dict]:
return [{field: getattr(row, field) for field in fields} for row in rows]
def _csv_bytes(rows: list[dict]) -> bytes:
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return output.getvalue().encode()
def build_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> None:
job = get_export(db, space_id=space_id, export_id=export_id)
if job.status == "ready":
return
job.status = "processing"
datasets = {
"accounts": _json_rows(
db.query(Account).filter_by(space_id=space_id).all(),
("id", "name", "kind", "currency", "opening_amount_kopecks", "opened_on", "status"),
),
"operations": _json_rows(
db.query(Operation).filter_by(space_id=space_id).all(),
(
"id",
"type",
"status",
"amount_kopecks",
"account_id",
"counter_account_id",
"category_id",
"occurred_on",
"posted_on",
"description",
),
),
"categories": _json_rows(
db.query(Category).filter_by(space_id=space_id).all(),
("id", "kind", "system_key", "name_override", "custom_name", "archived"),
),
"budget_periods": _json_rows(
db.query(BudgetPeriod).filter_by(space_id=space_id).all(),
("id", "year", "month", "status"),
),
"plan_assignments": _json_rows(
db.query(PlanAssignment).filter_by(space_id=space_id).all(),
("id", "period_id", "category_id", "planned_kopecks"),
),
"funding": _json_rows(
db.query(FundingMovement).filter_by(space_id=space_id).all(),
("id", "period_id", "category_id", "kind", "amount_kopecks"),
),
"expected_incomes": _json_rows(
db.query(ExpectedIncome).filter_by(space_id=space_id).all(),
("id", "period_id", "expected_on", "amount_kopecks", "source_name"),
),
"audit": _json_rows(
db.query(AuditEvent).filter_by(space_id=space_id).all(),
("id", "event_type", "created_at", "detail"),
),
}
raw = io.BytesIO()
with zipfile.ZipFile(raw, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name, rows in datasets.items():
archive.writestr(f"{name}.csv", _csv_bytes(rows))
archive.writestr("data.json", json.dumps(datasets, default=str, ensure_ascii=False, indent=2))
encrypted = encrypt_file(raw.getvalue())
key = new_object_key()
get_blob_store().put(key, encrypted.ciphertext)
job.object_key = key
job.dek_wrapped = encrypted.dek_wrapped
job.dek_wrap_nonce = encrypted.wrap_nonce
job.content_nonce = encrypted.content_nonce
job.expires_at = datetime.now(UTC) + timedelta(hours=1)
job.status = "ready"
def consume_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> bytes:
job = get_export(db, space_id=space_id, export_id=export_id)
now = datetime.now(UTC)
if job.status != "ready" or job.consumed_at is not None or not job.expires_at or job.expires_at <= now:
raise conflict("EXPORT_UNAVAILABLE", "Export is not ready, expired, or already downloaded")
if not all((job.object_key, job.dek_wrapped, job.dek_wrap_nonce, job.content_nonce)):
raise conflict("EXPORT_UNAVAILABLE", "Export key material is unavailable")
assert job.object_key is not None
assert job.dek_wrapped is not None
assert job.dek_wrap_nonce is not None
assert job.content_nonce is not None
raw = decrypt_file(
ciphertext=get_blob_store().get(job.object_key),
content_nonce=job.content_nonce,
dek_wrapped=job.dek_wrapped,
wrap_nonce=job.dek_wrap_nonce,
)
job.consumed_at = now
job.status = "consumed"
job.dek_wrapped = None
job.dek_wrap_nonce = None
job.content_nonce = None
return raw
def schedule_deletion(db: Session, *, space: FinancialSpace) -> datetime:
if space.status == "blocked" and space.deletion_scheduled_at:
return space.deletion_scheduled_at
when = datetime.now(UTC) + timedelta(days=get_settings().deletion_window_days)
space.status = "blocked"
space.deletion_scheduled_at = when
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
if marker is None:
db.add(DeletionLedger(space_id=space.id, scheduled_at=when))
else:
marker.scheduled_at = when
marker.completed_at = None
return when
def cancel_deletion(db: Session, *, space: FinancialSpace) -> None:
if space.status != "blocked" or not space.deletion_scheduled_at:
raise conflict("SPACE_DELETION_NOT_SCHEDULED", "Space deletion is not scheduled")
space.status = "active"
space.deletion_scheduled_at = None
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
if marker:
db.delete(marker)
def purge_due_spaces(db: Session) -> int:
now = datetime.now(UTC)
spaces = (
db.query(FinancialSpace)
.filter(
FinancialSpace.status == "blocked",
FinancialSpace.deletion_scheduled_at.is_not(None),
FinancialSpace.deletion_scheduled_at <= now,
)
.all()
)
store = get_blob_store()
purged = 0
for space in spaces:
from app.db.session import set_space_context
set_space_context(db, space.id)
for document in db.query(StatementDocument).filter_by(space_id=space.id).all():
store.delete(document.object_key)
document.dek_wrapped = None
for export in db.query(DataExport).filter_by(space_id=space.id).all():
if export.object_key:
store.delete(export.object_key)
export.dek_wrapped = None
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one()
marker.completed_at = now
db.delete(space)
purged += 1
return purged
+27
View File
@@ -0,0 +1,27 @@
import uuid
from app.db.session import session_scope
from app.exports.service import build_export, purge_due_spaces
from app.imports.celery_app import celery_app
@celery_app.task(name="exports.build", bind=True, max_retries=2, default_retry_delay=20)
def build_export_task(self, export_id: str, space_id: str) -> str:
try:
with session_scope(uuid.UUID(space_id)) as db:
build_export(db, space_id=uuid.UUID(space_id), export_id=uuid.UUID(export_id))
except Exception as exc:
if self.request.retries < 2:
raise self.retry(exc=exc) from exc
raise
return export_id
def enqueue_export(export_id: uuid.UUID, space_id: uuid.UUID) -> None:
build_export_task.delay(str(export_id), str(space_id))
@celery_app.task(name="spaces.purge_due")
def purge_due_spaces_task() -> int:
with session_scope() as db:
return purge_due_spaces(db)
View File
+22
View File
@@ -0,0 +1,22 @@
import smtplib
from email.message import EmailMessage
from app.config import get_settings
def send_token_email(*, recipient: str, token: str, kind: str) -> None:
settings = get_settings()
if not settings.smtp_host:
return
path = "invite/accept" if kind == "invite" else "reset-password"
link = f"{settings.public_base_url.rstrip('/')}/{path}?token={token}"
subject = "Finance invitation / Приглашение" if kind == "invite" else "Finance password reset"
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = recipient
message["Subject"] = subject
message.set_content(f"Open this one-time link / Откройте одноразовую ссылку:\n\n{link}\n")
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as client:
if settings.smtp_use_tls:
client.starttls()
client.send_message(message)
+65
View File
@@ -0,0 +1,65 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
class User(Base, PkMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
__table_args__ = (Index("uq_users_email_casefold", func.lower(email), unique=True),)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
locale: Mapped[str] = mapped_column(String(8), nullable=False, default="ru")
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="Europe/Moscow")
is_admin: Mapped[bool] = mapped_column(nullable=False, default=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
class Invitation(Base, PkMixin, TimestampMixin):
__tablename__ = "invitations"
__table_args__ = (UniqueConstraint("space_id", "email", name="uq_invitations_space_email"),)
space_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=True, index=True
)
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
invited_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="owner")
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
accepted_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class PasswordReset(Base, PkMixin, TimestampMixin):
__tablename__ = "password_resets"
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
class Session(Base, PkMixin, TimestampMixin):
__tablename__ = "sessions"
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
csrf_token: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
revoked_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
+164
View File
@@ -0,0 +1,164 @@
from typing import Annotated, Literal
from fastapi import APIRouter, Header, Request, Response
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.config import get_settings
from app.dependencies import CurrentSession, Db
from app.errors import forbidden
from app.identity import service
from app.identity.mailer import send_token_email
from app.ratelimit import allow
from app.security import constant_time_equals
router = APIRouter(tags=["identity"])
class InviteBody(BaseModel):
email: EmailStr
class AcceptInviteBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
token: str
password: str = Field(min_length=12, max_length=256)
display_name: str | None = Field(default=None, alias="fullName", max_length=120)
locale: Literal["ru", "en"] = "ru"
timezone: str = "Europe/Moscow"
class LoginBody(BaseModel):
email: EmailStr
password: str
class ResetRequestBody(BaseModel):
email: EmailStr
class ResetBody(BaseModel):
token: str
password: str = Field(min_length=12, max_length=256)
def _set_auth_cookies(response: Response, *, token: str, csrf: str) -> None:
settings = get_settings()
max_age = settings.session_ttl_days * 86400
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=max_age,
path="/",
)
response.set_cookie(
settings.csrf_cookie_name,
csrf,
httponly=False,
secure=settings.cookie_secure,
samesite="lax",
max_age=max_age,
path="/",
)
@router.post("/admin/invitations", status_code=201)
def create_invite(
body: InviteBody,
db: Db,
admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict:
expected = get_settings().admin_bootstrap_token
if not expected or not admin_token or not constant_time_equals(admin_token, expected):
raise forbidden("IDENTITY_ADMIN_TOKEN_INVALID", "Admin authorization required")
invitation, token = service.create_invitation(db, email=str(body.email))
db.commit()
send_token_email(recipient=invitation.email, token=token, kind="invite")
return {"id": invitation.id, "token": token, "expires_at": invitation.expires_at}
@router.post("/auth/invitations/accept", status_code=201)
def accept_invite(body: AcceptInviteBody, db: Db, response: Response) -> dict:
user = service.accept_invitation(db, **body.model_dump(by_alias=False))
session, token = service.create_session(db, user=user)
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
return {
"id": user.id,
"email": user.email,
"fullName": user.display_name or "",
"locale": user.locale,
"timezone": user.timezone,
}
@router.post("/auth/login")
def login(body: LoginBody, db: Db, response: Response, request: Request) -> dict:
client = request.client.host if request.client else "unknown"
if not allow(f"login:{client}:{str(body.email).casefold()}", limit=10, window_seconds=900):
from app.errors import AppError
raise AppError("Too many login attempts", code="RATE_LIMITED", status_code=429)
user = service.authenticate(db, email=str(body.email), password=body.password)
session, token = service.create_session(db, user=user)
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
return {
"id": user.id,
"email": user.email,
"fullName": user.display_name or "",
"locale": user.locale,
"timezone": user.timezone,
}
@router.post("/auth/password-reset", status_code=202)
def request_password_reset(body: ResetRequestBody, db: Db, request: Request) -> dict:
client = request.client.host if request.client else "unknown"
if not allow(f"password-reset:{client}", limit=5, window_seconds=900):
from app.errors import AppError
raise AppError("Too many reset attempts", code="RATE_LIMITED", status_code=429)
token = service.create_password_reset(db, email=str(body.email))
if token:
send_token_email(recipient=str(body.email), token=token, kind="reset")
result: dict[str, object] = {"accepted": True}
if get_settings().environment == "development" and token:
result["token"] = token
return result
@router.post("/auth/password-reset/confirm")
def confirm_password_reset(body: ResetBody, db: Db, response: Response) -> dict:
user = service.reset_password(db, token=body.token, password=body.password)
session, token = service.create_session(db, user=user)
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
return {
"id": user.id,
"email": user.email,
"fullName": user.display_name or "",
"locale": user.locale,
"timezone": user.timezone,
}
@router.get("/auth/me")
def me(auth: CurrentSession) -> dict:
user, _ = auth
return {
"id": user.id,
"email": user.email,
"fullName": user.display_name or "",
"locale": user.locale,
"timezone": user.timezone,
}
@router.post("/auth/logout", status_code=204)
def logout(auth: CurrentSession, db: Db, response: Response) -> None:
_, session = auth
service.revoke_session(db, session=session)
settings = get_settings()
response.delete_cookie(settings.session_cookie_name, path="/")
response.delete_cookie(settings.csrf_cookie_name, path="/")
+188
View File
@@ -0,0 +1,188 @@
import uuid
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session as DbSession
from app.audit import service as audit
from app.config import get_settings
from app.errors import bad_request, conflict, unauthorized
from app.identity.models import Invitation, PasswordReset, Session, User
from app.security import hash_password, new_token, token_digest, verify_password
from app.spaces.service import create_space
def normalize_email(email: str) -> str:
return email.strip().casefold()
def validate_profile(locale: str, timezone: str) -> None:
if locale not in {"ru", "en"}:
raise bad_request("IDENTITY_LOCALE_INVALID", "Locale must be ru or en")
try:
ZoneInfo(timezone)
except ZoneInfoNotFoundError as exc:
raise bad_request("IDENTITY_TIMEZONE_INVALID", "Unknown timezone") from exc
def create_invitation(
db: DbSession,
*,
email: str,
invited_by_user_id: uuid.UUID | None = None,
space_id: uuid.UUID | None = None,
) -> tuple[Invitation, str]:
normalized = normalize_email(email)
if db.query(User.id).filter(User.email == normalized).first() is not None:
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
token = new_token()
invitation = Invitation(
email=normalized,
token_hash=token_digest(token),
invited_by_user_id=invited_by_user_id,
space_id=space_id,
role="owner" if space_id is None else "member",
expires_at=datetime.now(UTC) + timedelta(days=get_settings().invite_ttl_days),
)
db.add(invitation)
db.flush()
audit.record(
db,
event_type="identity.invitation_created",
user_id=invited_by_user_id,
detail={"invitation_id": str(invitation.id)},
)
return invitation, token
def accept_invitation(
db: DbSession,
*,
token: str,
password: str,
display_name: str | None,
locale: str,
timezone: str,
) -> User:
validate_profile(locale, timezone)
if len(password) < 12:
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
invitation = (
db.query(Invitation)
.filter(Invitation.token_hash == token_digest(token))
.with_for_update()
.one_or_none()
)
now = datetime.now(UTC)
if invitation is None or invitation.accepted_at is not None or invitation.expires_at <= now:
raise unauthorized("IDENTITY_INVITATION_INVALID", "Invitation is invalid or expired")
if db.query(User.id).filter(User.email == invitation.email).first() is not None:
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
user = User(
email=invitation.email,
password_hash=hash_password(password),
display_name=display_name,
locale=locale,
timezone=timezone,
)
db.add(user)
db.flush()
if invitation.space_id is None:
space_name = f"{display_name or invitation.email} finance"
space = create_space(db, user=user, name=space_name)
invitation.space_id = space.id
else:
from app.spaces.models import Membership
db.add(Membership(space_id=invitation.space_id, user_id=user.id, role=invitation.role))
invitation.accepted_at = now
invitation.accepted_user_id = user.id
audit.record(db, event_type="identity.invitation_accepted", user_id=user.id)
return user
def authenticate(db: DbSession, *, email: str, password: str) -> User:
user = (
db.query(User)
.filter(User.email == normalize_email(email), User.status == "active")
.one_or_none()
)
if user is None or not verify_password(user.password_hash, password):
raise unauthorized("IDENTITY_CREDENTIALS_INVALID", "Invalid email or password")
return user
def create_session(db: DbSession, *, user: User) -> tuple[Session, str]:
token = new_token()
now = datetime.now(UTC)
session = Session(
user_id=user.id,
token_hash=token_digest(token),
csrf_token=new_token(),
expires_at=now + timedelta(days=get_settings().session_ttl_days),
last_seen_at=now,
)
db.add(session)
db.flush()
audit.record(db, event_type="identity.session_created", user_id=user.id)
return session, token
def get_active_session(db: DbSession, *, token: str) -> Session:
session = db.query(Session).filter(Session.token_hash == token_digest(token)).one_or_none()
now = datetime.now(UTC)
if session is None or session.revoked_at is not None or session.expires_at <= now:
raise unauthorized("IDENTITY_SESSION_INVALID", "Authentication required")
session.last_seen_at = now
return session
def revoke_session(db: DbSession, *, session: Session, reason: str = "logout") -> None:
if session.revoked_at is None:
session.revoked_at = datetime.now(UTC)
session.revoked_reason = reason
audit.record(db, event_type="identity.session_revoked", user_id=session.user_id)
def create_password_reset(db: DbSession, *, email: str) -> str | None:
user = (
db.query(User)
.filter(User.email == normalize_email(email), User.status == "active")
.one_or_none()
)
if user is None:
return None
token = new_token()
db.add(
PasswordReset(
user_id=user.id,
token_hash=token_digest(token),
expires_at=datetime.now(UTC) + timedelta(hours=get_settings().reset_ttl_hours),
)
)
audit.record(db, event_type="identity.password_reset_requested", user_id=user.id)
return token
def reset_password(db: DbSession, *, token: str, password: str) -> User:
if len(password) < 12:
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
reset = (
db.query(PasswordReset)
.filter(PasswordReset.token_hash == token_digest(token))
.with_for_update()
.one_or_none()
)
now = datetime.now(UTC)
if reset is None or reset.used_at is not None or reset.expires_at <= now:
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
user = db.get(User, reset.user_id)
if user is None:
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
user.password_hash = hash_password(password)
reset.used_at = now
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update(
{Session.revoked_at: now, Session.revoked_reason: "password_reset"}
)
audit.record(db, event_type="identity.password_reset_completed", user_id=user.id)
return user
View File
+38
View File
@@ -0,0 +1,38 @@
from app.imports.adapters.alfa_xlsx import AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter
from app.imports.adapters.base import StatementAdapter, UnsupportedStatementLayout
from app.imports.adapters.ozon_pdf import OzonCreditPdfAdapter, OzonDebitPdfAdapter
from app.imports.adapters.rocketbank_pdf import RocketbankDebitPdfAdapter
ADAPTERS: tuple[StatementAdapter, ...] = (
AlfaDebitXlsxAdapter(),
AlfaCreditXlsxAdapter(),
OzonDebitPdfAdapter(),
OzonCreditPdfAdapter(),
RocketbankDebitPdfAdapter(),
)
XLSX_MAGIC = b"PK\x03\x04"
PDF_MAGIC = b"%PDF-"
def magic_kind(raw: bytes) -> str | None:
if raw.startswith(XLSX_MAGIC):
return "xlsx"
if raw.startswith(PDF_MAGIC):
return "pdf"
return None
def detect_adapter(raw: bytes, magic: str) -> StatementAdapter:
attempted: list[str] = []
for adapter in ADAPTERS:
expected = "xlsx" if adapter.adapter_id.endswith("xlsx") else "pdf"
if expected != magic:
continue
attempted.append(adapter.adapter_id)
if adapter.detect(raw):
return adapter
raise UnsupportedStatementLayout(
"no registered adapter recognizes this document layout; "
f"attempted: {', '.join(attempted) or 'none for this file type'}"
)
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from datetime import date, datetime
from app.errors import AppError
from app.imports.adapters.base import (
DIRECTION_IN,
DIRECTION_OUT,
PARSER_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
amount_to_kopecks,
)
from app.imports.readers import open_xlsx
DATE_HEADERS = ("дата операции", "дата")
AMOUNT_HEADERS = ("сумма в валюте счёта", "сумма операции", "сумма")
DESCRIPTION_HEADERS = ("описание", "назначение", "комментарий")
CATEGORY_HEADERS = ("категория",)
ACCOUNT_HEADERS = ("номер карты", "номер счёта", "карта")
class AlfaXlsxAdapterBase:
"""Alfa statement contract: XLSX with a header row and bank marker 'Альфа'."""
adapter_id: str = ""
product: str = ""
product_markers: tuple[str, ...] = ()
def detect(self, raw: bytes) -> bool:
try:
text = _scan_text(raw)
except AppError:
return False
lowered = text.lower()
return "альфа" in lowered and any(m in lowered for m in self.product_markers)
def parse(self, raw: bytes) -> StatementDraft:
with open_xlsx(raw) as workbook:
sheet = workbook.worksheets[0]
rows = []
for index, row in enumerate(sheet.iter_rows(values_only=True)):
if index > get_settings_row_cap():
raise UnsupportedStatementLayout("row quota exceeded during parse")
rows.append(row)
header_idx, columns = _find_header(rows)
full_text = _rows_text(rows).lower()
if "альфа" not in full_text:
raise UnsupportedStatementLayout("Alfa bank marker not found")
if not any(m in full_text for m in self.product_markers):
raise UnsupportedStatementLayout(
f"expected {self.product} statement markers not found"
)
fingerprint = None
masked = _find_account_value(rows)
if masked:
fingerprint = account_fingerprint("alfa", masked)
parsed: list[SourceRow] = []
for offset, row in enumerate(rows[header_idx + 1 :], start=header_idx + 1):
if all(cell is None for cell in row):
continue
occurred = _parse_date(row[columns["date"]])
amount_raw = row[columns["amount"]]
warnings: list[str] = []
if occurred is None:
warnings.append("occurred_date_missing")
try:
amount = amount_to_kopecks(amount_raw)
except UnsupportedStatementLayout:
amount = 0
warnings.append("amount_unparseable")
description = (
_cell_str(row[columns["description"]]) if columns.get("description") else None
)
bank_category = (
_cell_str(row[columns["category"]]) if columns.get("category") else None
)
external_id = (
_cell_str(row[columns["external"]]) if columns.get("external") else None
)
parsed.append(
SourceRow(
external_id=external_id,
occurred_on=occurred,
posted_on=None,
amount_kopecks=amount,
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
description=description,
mcc=None,
bank_category=bank_category,
location=None,
row_ref=f"sheet0:row{offset + 1}",
warnings=tuple(warnings),
)
)
return StatementDraft(
adapter_id=self.adapter_id,
parser_version=PARSER_VERSION,
bank_code="alfa",
product=self.product,
account_fingerprint=fingerprint,
currency="RUB",
period_start=min((r.occurred_on for r in parsed if r.occurred_on), default=None),
period_end=max((r.occurred_on for r in parsed if r.occurred_on), default=None),
opening_balance_kopecks=None,
closing_balance_kopecks=None,
rows=tuple(parsed),
)
def get_settings_row_cap() -> int:
from app.config import get_settings
return get_settings().max_xlsx_rows
def _scan_text(raw: bytes) -> str:
with open_xlsx(raw) as workbook:
return _rows_text(
[
row
for index, row in enumerate(workbook.worksheets[0].iter_rows(values_only=True))
if index < 40
]
)
def _rows_text(rows: list[tuple]) -> str:
parts = []
for row in rows:
for cell in row:
if cell is not None:
parts.append(str(cell))
return " | ".join(parts)
def _cell_str(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _parse_date(value: object) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
for index, row in enumerate(rows[:20]):
cells = [str(c).strip().lower() if c is not None else "" for c in row]
date_col = next((i for i, c in enumerate(cells) if c in DATE_HEADERS), None)
amount_col = next((i for i, c in enumerate(cells) if c in AMOUNT_HEADERS), None)
if date_col is None or amount_col is None:
continue
columns = {"date": date_col, "amount": amount_col}
for key, synonyms in (
("description", DESCRIPTION_HEADERS),
("category", CATEGORY_HEADERS),
("external", ("id операции", "номер операции")),
):
columns[key] = next( # type: ignore[assignment]
(i for i, c in enumerate(cells) if c in synonyms), None
)
return index, columns
raise UnsupportedStatementLayout("statement header row with date/amount columns not found")
def _find_account_value(rows: list[tuple]) -> str | None:
for row in rows[:20]:
for index, cell in enumerate(row):
if cell is None:
continue
header = str(row[index - 1]).strip().lower() if index > 0 and row[index - 1] else ""
if header in ACCOUNT_HEADERS:
return str(cell).strip()
masked = None
for row in rows[:20]:
for cell in row:
if cell is None:
continue
text = str(cell)
if len(text) >= 4 and any(ch.isdigit() for ch in text) and ("*" in text or "•" in text):
masked = text.strip()
return masked
class AlfaDebitXlsxAdapter(AlfaXlsxAdapterBase):
adapter_id = "alfa_debit_xlsx"
product = "debit"
product_markers = ("дебет", "дебетовая")
class AlfaCreditXlsxAdapter(AlfaXlsxAdapterBase):
adapter_id = "alfa_credit_xlsx"
product = "credit"
product_markers = ("кредит", "кредитная")
+96
View File
@@ -0,0 +1,96 @@
import hashlib
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Protocol
from app.errors import AppError
DIRECTION_IN = "in"
DIRECTION_OUT = "out"
PARSER_VERSION = "1"
@dataclass(frozen=True)
class SourceRow:
external_id: str | None
occurred_on: date | None
posted_on: date | None
amount_kopecks: int
direction: str
description: str | None
mcc: int | None
bank_category: str | None
location: str | None
row_ref: str
warnings: tuple[str, ...] = ()
@dataclass(frozen=True)
class StatementDraft:
adapter_id: str
parser_version: str
bank_code: str
product: str
account_fingerprint: str | None
currency: str
period_start: date | None
period_end: date | None
opening_balance_kopecks: int | None
closing_balance_kopecks: int | None
rows: tuple[SourceRow, ...]
class UnsupportedStatementLayout(AppError):
"""Stable diagnostic for unknown bank layouts; never guesses."""
def __init__(self, detail: str) -> None:
super().__init__(
detail,
code="IMPORT_UNSUPPORTED_LAYOUT",
status_code=422,
)
class StatementAdapter(Protocol):
adapter_id: str
def detect(self, raw: bytes) -> bool: ...
def parse(self, raw: bytes) -> StatementDraft: ...
def amount_to_kopecks(value: object) -> int:
if isinstance(value, int):
return value
if isinstance(value, float):
raise UnsupportedStatementLayout(
"float amounts are not accepted; cell must be text/decimal"
)
if isinstance(value, Decimal):
return int(value.quantize(Decimal("0.01")).scaleb(2))
if isinstance(value, str):
cleaned = value.replace(" ", "").replace("\u00a0", "").replace(",", ".")
try:
return int(Decimal(cleaned).quantize(Decimal("0.01")).scaleb(2))
except (InvalidOperation, ValueError) as exc:
raise UnsupportedStatementLayout(f"unparseable amount: {value!r}") from exc
raise UnsupportedStatementLayout(f"unsupported amount cell type: {type(value).__name__}")
def account_fingerprint(bank_code: str, masked_identifier: str) -> str:
digest = hashlib.sha256(f"{bank_code}|{masked_identifier}".encode()).hexdigest()
return digest[:64]
def normalize_description(description: str | None) -> str:
if not description:
return ""
return " ".join(description.lower().split())
def record_fingerprint(
occurred_on: date | None, amount_kopecks: int, description: str | None
) -> str:
occurred = occurred_on.isoformat() if occurred_on else ""
payload = f"{occurred}|{amount_kopecks}|{normalize_description(description)[:120]}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import re
from datetime import date, datetime
from app.imports.adapters.base import (
DIRECTION_IN,
DIRECTION_OUT,
PARSER_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
amount_to_kopecks,
)
from app.imports.readers import open_pdf
PERIOD_RE = re.compile(
r"период:?\s*(\d{2}\.\d{2}\.\d{4})\s*[-–—]\s*(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
)
ROW_RE = re.compile(
r"^(?P<date>\d{2}\.\d{2}\.\d{4})\s+(?P<desc>.+?)\s+(?P<amount>-?\d[\d\s]*,\d{2})"
r"(?:\s*(?:₽|руб\.?|RUB))?$"
)
MASK_RE = re.compile(r"(?:\*{2,}|•{2,})\s*(\d{4})")
class OzonPdfAdapterBase:
"""Ozon statement contract: text-layer PDF, per-page line scanning."""
adapter_id: str = ""
product: str = ""
bank_markers: tuple[str, ...] = ("ozon банк", "озон банк", "ozon bank")
product_markers: tuple[str, ...] = ()
def detect(self, raw: bytes) -> bool:
try:
text = _first_page_text(raw)
except Exception:
return False
lowered = text.lower()
return any(m in lowered for m in self.bank_markers) and any(
m in lowered for m in self.product_markers
)
def parse(self, raw: bytes) -> StatementDraft:
with open_pdf(raw) as pdf:
lines: list[str] = []
period_start: date | None = None
period_end: date | None = None
masks: list[str] = []
for page_index, page in enumerate(pdf.pages):
text = page.extract_text() or ""
if page_index == 0 and not text.strip():
raise UnsupportedStatementLayout(
"PDF has no extractable text layer; OCR is not supported"
)
lowered = text.lower()
if not any(m in lowered for m in self.bank_markers):
raise UnsupportedStatementLayout("bank marker not found in PDF text")
if not any(m in lowered for m in self.product_markers):
raise UnsupportedStatementLayout(
f"expected {self.product} statement markers not found"
)
match = PERIOD_RE.search(text)
if match and period_start is None:
period_start = datetime.strptime(match.group(1), "%d.%m.%Y").date()
period_end = datetime.strptime(match.group(2), "%d.%m.%Y").date()
masks.extend(MASK_RE.findall(text))
for line in text.splitlines():
line = line.strip()
if ROW_RE.match(line):
lines.append(line)
if not lines:
raise UnsupportedStatementLayout("no statement rows matched the layout contract")
fingerprint = (
account_fingerprint(self.bank_markers[0].split()[0], f"**{masks[0]}")
if masks
else None
)
parsed = [_parse_row(line, f"page:line{n + 1}") for n, line in enumerate(lines)]
return StatementDraft(
adapter_id=self.adapter_id,
parser_version=PARSER_VERSION,
bank_code=self.bank_markers[0].split()[0],
product=self.product,
account_fingerprint=fingerprint,
currency="RUB",
period_start=period_start,
period_end=period_end,
opening_balance_kopecks=None,
closing_balance_kopecks=None,
rows=tuple(parsed),
)
def _parse_row(line: str, row_ref: str) -> SourceRow:
match = ROW_RE.match(line)
assert match is not None
occurred = datetime.strptime(match.group("date"), "%d.%m.%Y").date()
raw_amount = match.group("amount").replace(" ", "")
amount = amount_to_kopecks(raw_amount)
return SourceRow(
external_id=None,
occurred_on=occurred,
posted_on=None,
amount_kopecks=amount,
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
description=match.group("desc").strip(),
mcc=None,
bank_category=None,
location=None,
row_ref=row_ref,
warnings=() if occurred else ("occurred_date_missing",),
)
def _first_page_text(raw: bytes) -> str:
with open_pdf(raw) as pdf:
if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages")
return pdf.pages[0].extract_text() or ""
class OzonDebitPdfAdapter(OzonPdfAdapterBase):
adapter_id = "ozon_debit_pdf"
product = "debit"
product_markers = ("дебетов",)
class OzonCreditPdfAdapter(OzonPdfAdapterBase):
adapter_id = "ozon_credit_pdf"
product = "credit"
product_markers = ("кредитн", "кредитная")
@@ -0,0 +1,12 @@
from __future__ import annotations
from app.imports.adapters.ozon_pdf import OzonPdfAdapterBase
class RocketbankDebitPdfAdapter(OzonPdfAdapterBase):
"""Rocketbank debit statement contract: same text-layer line scanning shape."""
adapter_id = "rocketbank_debit_pdf"
product = "debit"
bank_markers = ("рокетбанк", "rocketbank")
product_markers = ("дебетов", "дебетовая")
+77
View File
@@ -0,0 +1,77 @@
import os
from pathlib import Path
from typing import Protocol
import boto3
from botocore.exceptions import ClientError
from app.config import get_settings
class BlobStore(Protocol):
def put(self, key: str, data: bytes) -> None: ...
def get(self, key: str) -> bytes: ...
def delete(self, key: str) -> None: ...
class LocalDirectoryBlobStore:
"""S3-neutral local store; production swaps in an S3-compatible client."""
def __init__(self, base_dir: str) -> None:
self._base = Path(base_dir)
def _path(self, key: str) -> Path:
if "/" in key or ".." in key or not key:
raise ValueError("invalid blob key")
return self._base / key
def put(self, key: str, data: bytes) -> None:
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def get(self, key: str) -> bytes:
return self._path(key).read_bytes()
def delete(self, key: str) -> None:
path = self._path(key)
if path.exists():
path.unlink()
class S3BlobStore:
def __init__(self) -> None:
settings = get_settings()
self._bucket = settings.s3_bucket_statements
self._client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
region_name=settings.s3_region,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
)
try:
self._client.head_bucket(Bucket=self._bucket)
except ClientError:
self._client.create_bucket(Bucket=self._bucket)
def put(self, key: str, data: bytes) -> None:
self._client.put_object(Bucket=self._bucket, Key=key, Body=data)
def get(self, key: str) -> bytes:
response = self._client.get_object(Bucket=self._bucket, Key=key)
return response["Body"].read()
def delete(self, key: str) -> None:
self._client.delete_object(Bucket=self._bucket, Key=key)
def get_blob_store() -> BlobStore:
settings = get_settings()
if settings.s3_endpoint_url:
return S3BlobStore()
return LocalDirectoryBlobStore(settings.blob_dir)
def new_object_key() -> str:
return os.urandom(16).hex()
+34
View File
@@ -0,0 +1,34 @@
from celery import Celery
from app.config import get_settings
def create_celery_app() -> Celery:
settings = get_settings()
app = Celery(
"finance",
broker=settings.broker_url,
include=["app.imports.tasks", "app.exports.tasks"],
)
app.conf.update(
task_acks_late=True,
task_acks_on_failure_or_timeout=False,
worker_prefetch_multiplier=1,
worker_max_tasks_per_child=50,
worker_max_memory_per_child=250_000,
task_time_limit=600,
task_soft_time_limit=480,
task_always_eager=settings.celery_task_always_eager,
broker_connection_retry_on_startup=True,
task_default_queue="finance",
beat_schedule={
"purge-due-spaces-hourly": {
"task": "spaces.purge_due",
"schedule": 3600.0,
}
},
)
return app
celery_app = create_celery_app()
+58
View File
@@ -0,0 +1,58 @@
import os
from dataclasses import dataclass
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import get_settings
from app.errors import AppError
_DEK_AAD = b"finance-import-dek-v1"
@dataclass(frozen=True)
class EncryptedFile:
ciphertext: bytes
content_nonce: bytes
dek_wrapped: bytes
wrap_nonce: bytes
def encrypt_file(plaintext: bytes) -> EncryptedFile:
"""Per-file random DEK; DEK wrapped with the KEK from the secret manager."""
kek = get_settings().kek
dek = os.urandom(32)
content_nonce = os.urandom(12)
ciphertext = AESGCM(dek).encrypt(content_nonce, plaintext, None)
wrap_nonce = os.urandom(12)
dek_wrapped = AESGCM(kek).encrypt(wrap_nonce, dek, _DEK_AAD)
return EncryptedFile(
ciphertext=ciphertext,
content_nonce=content_nonce,
dek_wrapped=dek_wrapped,
wrap_nonce=wrap_nonce,
)
def decrypt_file(
*,
ciphertext: bytes,
content_nonce: bytes,
dek_wrapped: bytes,
wrap_nonce: bytes,
) -> bytes:
kek = get_settings().kek
try:
dek = AESGCM(kek).decrypt(wrap_nonce, dek_wrapped, _DEK_AAD)
return AESGCM(dek).decrypt(content_nonce, ciphertext, None)
except InvalidTag as exc:
raise AppError(
"Encrypted document failed integrity verification",
code="IMPORT_DECRYPT_FAILED",
status_code=409,
) from exc
def shred_dek_material() -> None:
"""Crypto-shred hook: deleting wrapped DEK rows in DB plus blobs removes access."""
return None
+211
View File
@@ -0,0 +1,211 @@
import uuid
from datetime import date
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Date,
ForeignKey,
ForeignKeyConstraint,
Integer,
LargeBinary,
String,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
DOCUMENT_STATUSES = (
"uploaded",
"queued",
"processing",
"ready",
"review_required",
"confirmed",
"failed",
"deleted",
)
RUN_STATUSES = ("pending", "running", "succeeded", "failed")
CANDIDATE_KINDS = ("duplicate", "transfer")
CANDIDATE_CONFIDENCES = ("unique", "possible")
CANDIDATE_RESOLUTIONS = ("pending", "is_duplicate", "is_new")
class StatementDocument(Base, PkMixin, TimestampMixin):
__tablename__ = "statement_documents"
__table_args__ = (
ForeignKeyConstraint(
["space_id", "account_id"],
["accounts.space_id", "accounts.id"],
name="fk_documents_account_tenant",
),
UniqueConstraint("space_id", "content_sha256", name="uq_documents_space_sha"),
UniqueConstraint("space_id", "id", name="uq_documents_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
original_filename: Mapped[str] = mapped_column(String(300), nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
object_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
dek_wrapped: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
dek_wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
content_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
adapter_id: Mapped[str | None] = mapped_column(String(48), nullable=True)
account_fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="uploaded", index=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
reconciliation: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
needs_account_mapping: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
active_parse_run_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
uploaded_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class ParseRun(Base, PkMixin, TimestampMixin):
__tablename__ = "parse_runs"
__table_args__ = (
ForeignKeyConstraint(
["space_id", "document_id"],
["statement_documents.space_id", "statement_documents.id"],
ondelete="CASCADE",
name="fk_parse_runs_document_tenant",
),
UniqueConstraint("space_id", "id", name="uq_parse_runs_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
document_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
adapter_id: Mapped[str | None] = mapped_column(String(48), nullable=True)
parser_version: Mapped[str] = mapped_column(String(16), nullable=False, default="1")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
draft: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
class SourceRecord(Base, PkMixin, TimestampMixin):
"""Immutable extracted bank fact with document origin coordinates."""
__tablename__ = "source_records"
__table_args__ = (
ForeignKeyConstraint(
["space_id", "document_id"],
["statement_documents.space_id", "statement_documents.id"],
name="fk_source_records_document_tenant",
),
ForeignKeyConstraint(
["space_id", "parse_run_id"],
["parse_runs.space_id", "parse_runs.id"],
ondelete="CASCADE",
name="fk_source_records_run_tenant",
),
ForeignKeyConstraint(
["space_id", "duplicate_operation_id"],
["operations.space_id", "operations.id"],
name="fk_source_records_duplicate_tenant",
),
UniqueConstraint("space_id", "id", name="uq_source_records_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
parse_run_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
external_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
occurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
posted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
direction: Mapped[str] = mapped_column(String(8), nullable=False)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
bank_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
location: Mapped[str | None] = mapped_column(String(200), nullable=True)
row_ref: Mapped[str] = mapped_column(String(120), nullable=False)
field_warnings: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
critical: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
record_fp: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
review_decision: Mapped[str] = mapped_column(String(16), nullable=False, default="create")
duplicate_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
class SourceRecordLink(Base, PkMixin, TimestampMixin):
__tablename__ = "source_record_links"
__table_args__ = (
ForeignKeyConstraint(
["space_id", "source_record_id"],
["source_records.space_id", "source_records.id"],
ondelete="CASCADE",
name="fk_links_source_record_tenant",
),
ForeignKeyConstraint(
["space_id", "operation_id"],
["operations.space_id", "operations.id"],
name="fk_links_operation_tenant",
),
UniqueConstraint("source_record_id", name="uq_links_source_record"),
UniqueConstraint("space_id", "id", name="uq_links_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
source_record_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
class DedupCandidate(Base, PkMixin, TimestampMixin):
__tablename__ = "dedup_candidates"
__table_args__ = (
CheckConstraint("candidate_kind IN ('duplicate', 'transfer')", name="kind_valid"),
CheckConstraint(
"resolution IN ('pending', 'is_duplicate', 'is_new', 'accepted_transfer')",
name="resolution_valid",
),
ForeignKeyConstraint(
["space_id", "source_record_id"],
["source_records.space_id", "source_records.id"],
ondelete="CASCADE",
name="fk_candidates_source_tenant",
),
ForeignKeyConstraint(
["space_id", "existing_operation_id"],
["operations.space_id", "operations.id"],
name="fk_candidates_operation_tenant",
),
ForeignKeyConstraint(
["space_id", "existing_source_record_id"],
["source_records.space_id", "source_records.id"],
ondelete="CASCADE",
name="fk_candidates_existing_source_tenant",
),
UniqueConstraint("space_id", "id", name="uq_candidates_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
source_record_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
candidate_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="duplicate")
existing_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
existing_source_record_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, nullable=True, index=True
)
confidence: Mapped[str] = mapped_column(String(16), nullable=False, default="possible")
resolution: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
replacement_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
decided_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+57
View File
@@ -0,0 +1,57 @@
import io
import zipfile
from collections.abc import Iterator
from contextlib import contextmanager
import defusedxml # noqa: F401 (hardened XML stack must be importable for openpyxl)
import pdfplumber
from openpyxl import load_workbook
from app.config import get_settings
from app.errors import AppError
def _quota_error() -> AppError:
return AppError(
"Document exceeds parser resource quotas",
code="IMPORT_QUOTA_EXCEEDED",
status_code=413,
)
@contextmanager
def open_xlsx(raw: bytes) -> Iterator:
settings = get_settings()
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
total_uncompressed = 0
for info in zf.infolist():
if info.flag_bits & 0x1:
raise AppError(
"Encrypted archives are not supported",
code="IMPORT_ENCRYPTED_ARCHIVE",
status_code=422,
)
total_uncompressed += info.file_size
if total_uncompressed > settings.max_archive_uncompressed_bytes:
raise _quota_error()
except zipfile.BadZipFile as exc:
raise AppError(
"File is not a valid XLSX container",
code="IMPORT_FILE_CORRUPT",
status_code=422,
) from exc
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True, keep_links=False)
try:
yield workbook
finally:
workbook.close()
@contextmanager
def open_pdf(raw: bytes) -> Iterator:
settings = get_settings()
with pdfplumber.open(io.BytesIO(raw)) as pdf:
if len(pdf.pages) > settings.max_pdf_pages:
raise _quota_error()
yield pdf
+248
View File
@@ -0,0 +1,248 @@
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, File, Form, UploadFile
from pydantic import BaseModel, ConfigDict, Field
from app.dependencies import Context, Csrf, Db
from app.imports import service
from app.imports.tasks import enqueue_process_statement
router = APIRouter(prefix="/imports", tags=["imports"])
class ReviewBody(BaseModel):
account_id: uuid.UUID
confirmed_without_full_reconciliation: bool = False
reason: str | None = None
class DecisionBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
decision: str
duplicate_operation_id: uuid.UUID | None = Field(default=None, alias="duplicateOfOperationId")
class ConfirmBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
confirmed_without_full_reconciliation: bool = Field(
default=False, alias="confirmedWithoutFullReconciliation"
)
reason: str | None = None
def summary(document: Any) -> dict:
return {
"id": document.id,
"fileName": document.original_filename,
"status": document.status,
"bankAdapter": document.adapter_id or "detecting",
"accountId": document.account_id,
"accountName": None,
"periodFrom": None,
"periodTo": None,
"needsAccountMapping": document.needs_account_mapping,
"errorCode": document.error_code,
"createdAt": document.created_at,
}
@router.get("")
def imports(db: Db, context: Context) -> list[dict]:
return [summary(item) for item in service.list_documents(db, space_id=context.space.id)]
@router.post("", status_code=202)
async def upload(
file: Annotated[UploadFile, File()],
db: Db,
context: Context,
_csrf: Csrf,
account_id: Annotated[uuid.UUID | None, Form(alias="accountId")] = None,
) -> dict:
from app.config import get_settings
raw = await file.read(get_settings().max_upload_bytes + 1)
document, created = service.create_document(
db,
space_id=context.space.id,
user=context.user,
filename=file.filename or "statement",
raw=raw,
account_id=account_id,
)
if created:
db.commit()
try:
enqueue_process_statement(document.id, context.space.id)
except Exception as exc:
from app.db.session import set_space_context
from app.errors import AppError
set_space_context(db, context.space.id)
document = service._document(db, space_id=context.space.id, document_id=document.id)
document.status = "failed"
document.error_code = "IMPORT_QUEUE_FAILED"
db.commit()
raise AppError(
"Import queue is unavailable",
code="IMPORT_QUEUE_FAILED",
status_code=503,
) from exc
return summary(document)
@router.get("/{document_id}")
def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
raw = service.document_detail(db, space_id=context.space.id, document_id=document_id)
document = service._document(db, space_id=context.space.id, document_id=document_id)
base = summary(document)
reconciliation = raw.get("reconciliation") or {}
rows = [
{
"id": item["id"],
"externalId": item["external_id"],
"occurredOn": item["occurred_on"],
"amountKopecks": item["amount_kopecks"],
"direction": item["direction"],
"description": item["description"],
"payeeHint": None,
"warnings": (item["warnings"] or {}).get("codes", []),
"decision": "pending" if item["critical"] else item["review_decision"],
"duplicateOfOperationId": item["duplicate_operation_id"],
}
for item in raw["records"]
]
blockers = [
{"code": "IMPORT_FIELD_UNRESOLVED", "message": None, "rowId": str(item["id"])}
for item in raw["records"]
if item["critical"]
]
status = (
"matched"
if reconciliation.get("full")
else "confirmed_without"
if reconciliation.get("confirmed_without_full_reconciliation")
else "not_available"
)
return {
**base,
"failureReason": raw["error_code"],
"blockers": blockers,
"warnings": [],
"reconciliation": {
"status": status,
"openingBalanceKopecks": reconciliation.get("opening_balance_kopecks"),
"closingBalanceKopecks": reconciliation.get("closing_balance_kopecks"),
"computedClosingKopecks": None,
"reason": reconciliation.get("reason"),
},
"rows": rows,
"totals": {
"total": len(rows),
"create": sum(row["decision"] == "create" for row in rows),
"discard": sum(row["decision"] == "discard" for row in rows),
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
"transfer": sum(row["decision"] == "transfer" for row in rows),
},
}
@router.post("/{document_id}/review")
def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _csrf: Csrf) -> dict:
document = service.set_review_confirmation(
db,
space_id=context.space.id,
document_id=document_id,
**body.model_dump(),
)
return summary(document)
@router.patch("/{document_id}/rows/{row_id}")
def decide_row(
document_id: uuid.UUID,
row_id: uuid.UUID,
body: DecisionBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
service.decide_source_record(
db,
space_id=context.space.id,
document_id=document_id,
source_record_id=row_id,
user=context.user,
**body.model_dump(by_alias=False),
)
return detail(document_id, db, context)
@router.post("/{document_id}/confirm")
def confirm(
document_id: uuid.UUID,
body: ConfirmBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
document = service._document(db, space_id=context.space.id, document_id=document_id)
if body.confirmed_without_full_reconciliation and document.account_id:
service.set_review_confirmation(
db,
space_id=context.space.id,
document_id=document_id,
account_id=document.account_id,
confirmed_without_full_reconciliation=True,
reason=body.reason,
)
document = service.confirm_document(
db, space_id=context.space.id, document_id=document_id, user=context.user
)
return detail(document_id, db, context)
@router.post("/{document_id}/reparse", status_code=202)
def reparse(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
document = service.prepare_reparse(
db, space_id=context.space.id, document_id=document_id, user=context.user
)
db.commit()
from app.db.session import set_space_context
try:
enqueue_process_statement(document.id, context.space.id)
except Exception as exc:
from app.errors import AppError
set_space_context(db, context.space.id)
document = service._document(db, space_id=context.space.id, document_id=document.id)
document.status = "failed"
document.error_code = "IMPORT_QUEUE_FAILED"
db.commit()
raise AppError(
"Import queue is unavailable",
code="IMPORT_QUEUE_FAILED",
status_code=503,
) from exc
set_space_context(db, context.space.id)
return detail(document_id, db, context)
@router.post("/{document_id}/revert")
def revert(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
service.revert_document(
db, space_id=context.space.id, document_id=document_id, user=context.user
)
return detail(document_id, db, context)
@router.delete("/{document_id}/source")
def delete_source(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
document = service.delete_source_file(
db, space_id=context.space.id, document_id=document_id, user=context.user
)
return summary(document)
+921
View File
@@ -0,0 +1,921 @@
import hashlib
import uuid
from dataclasses import asdict
from datetime import date, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.config import get_settings
from app.db.session import session_scope
from app.errors import AppError, conflict, not_found
from app.identity.models import User
from app.imports.adapters import detect_adapter, magic_kind
from app.imports.adapters.base import SourceRow, StatementDraft, record_fingerprint
from app.imports.blob import get_blob_store, new_object_key
from app.imports.crypto import decrypt_file, encrypt_file
from app.imports.models import (
DedupCandidate,
ParseRun,
SourceRecord,
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Account, Operation
from app.ledger.service import (
create_expense,
create_income,
create_transfer,
ensure_period_open_for_date,
)
def _document(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> StatementDocument:
document = (
db.query(StatementDocument).filter_by(id=document_id, space_id=space_id).one_or_none()
)
if document is None:
raise not_found("IMPORT_NOT_FOUND", "Statement import not found")
return document
def create_document(
db: Session,
*,
space_id: uuid.UUID,
user: User,
filename: str,
raw: bytes,
account_id: uuid.UUID | None,
) -> tuple[StatementDocument, bool]:
settings = get_settings()
if not raw or len(raw) > settings.max_upload_bytes:
raise AppError(
"File size is outside the allowed range", code="IMPORT_SIZE_INVALID", status_code=413
)
kind = magic_kind(raw)
if kind is None:
raise AppError(
"Only XLSX and text-layer PDF statements are supported",
code="IMPORT_TYPE_INVALID",
status_code=415,
)
suffix = Path(filename).suffix.casefold()
if suffix not in ({".xlsx"} if kind == "xlsx" else {".pdf"}):
raise AppError(
"File extension does not match its content",
code="IMPORT_TYPE_MISMATCH",
status_code=422,
)
if account_id is not None:
account = db.query(Account.id).filter_by(id=account_id, space_id=space_id).one_or_none()
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
digest = hashlib.sha256(raw).hexdigest()
existing = (
db.query(StatementDocument)
.filter_by(space_id=space_id, content_sha256=digest)
.one_or_none()
)
if existing is not None:
return existing, False
encrypted = encrypt_file(raw)
object_key = new_object_key()
get_blob_store().put(object_key, encrypted.ciphertext)
try:
document = StatementDocument(
space_id=space_id,
account_id=account_id,
original_filename=Path(filename).name[:300],
size_bytes=len(raw),
content_sha256=digest,
object_key=object_key,
dek_wrapped=encrypted.dek_wrapped,
dek_wrap_nonce=encrypted.wrap_nonce,
content_nonce=encrypted.content_nonce,
status="queued",
uploaded_by_user_id=user.id,
)
db.add(document)
db.flush()
except Exception:
get_blob_store().delete(object_key)
raise
audit.record(
db,
event_type="import.uploaded",
space_id=space_id,
user_id=user.id,
detail={"document_id": str(document.id), "size_bytes": len(raw)},
)
return document, True
def _draft_json(draft: StatementDraft) -> dict[str, Any]:
value = asdict(draft)
for key in ("period_start", "period_end"):
item = value[key]
value[key] = item.isoformat() if item else None
for row in value["rows"]:
for key in ("occurred_on", "posted_on"):
item = row[key]
row[key] = item.isoformat() if item else None
return value
def _source_from_row(
*, space_id: uuid.UUID, document_id: uuid.UUID, run_id: uuid.UUID, row: SourceRow
) -> SourceRecord:
critical_warnings = {"occurred_date_missing", "amount_unparseable"}
warnings = list(row.warnings)
if row.direction not in {"in", "out"}:
warnings.append("direction_invalid")
if row.amount_kopecks == 0:
warnings.append("amount_zero")
return SourceRecord(
space_id=space_id,
document_id=document_id,
parse_run_id=run_id,
external_id=row.external_id,
occurred_on=row.occurred_on,
posted_on=row.posted_on,
amount_kopecks=abs(row.amount_kopecks),
direction=row.direction,
description=row.description,
mcc=row.mcc,
bank_category=row.bank_category,
location=row.location,
row_ref=row.row_ref,
field_warnings={"codes": sorted(set(warnings))} if warnings else None,
critical=(
bool(set(warnings) & critical_warnings)
or row.amount_kopecks == 0
or row.direction not in {"in", "out"}
),
record_fp=record_fingerprint(row.occurred_on, abs(row.amount_kopecks), row.description),
)
def _build_candidates(db: Session, *, document: StatementDocument, run: ParseRun) -> bool:
pending = False
for source in db.query(SourceRecord).filter_by(parse_run_id=run.id).all():
duplicate = None
if source.external_id:
matches = (
db.query(SourceRecord)
.join(ParseRun, ParseRun.id == SourceRecord.parse_run_id)
.filter(
SourceRecord.space_id == document.space_id,
SourceRecord.id != source.id,
SourceRecord.external_id == source.external_id,
ParseRun.adapter_id == run.adapter_id,
)
.all()
)
duplicate = matches[0] if len(matches) == 1 else None
if duplicate is None:
matches = (
db.query(SourceRecord)
.filter(
SourceRecord.space_id == document.space_id,
SourceRecord.id != source.id,
SourceRecord.record_fp == source.record_fp,
SourceRecord.direction == source.direction,
)
.all()
)
duplicate = matches[0] if len(matches) == 1 else None
if duplicate is not None:
link = db.query(SourceRecordLink).filter_by(source_record_id=duplicate.id).one_or_none()
db.add(
DedupCandidate(
space_id=document.space_id,
source_record_id=source.id,
candidate_kind="duplicate",
existing_source_record_id=duplicate.id,
existing_operation_id=link.operation_id if link else None,
confidence="strong" if source.external_id else "possible",
)
)
pending = True
continue
if source.occurred_on is None:
continue
opposite = "in" if source.direction == "out" else "out"
transfer_matches = (
db.query(SourceRecord)
.join(SourceRecordLink, SourceRecordLink.source_record_id == SourceRecord.id)
.filter(
SourceRecord.space_id == document.space_id,
SourceRecord.id != source.id,
SourceRecord.amount_kopecks == source.amount_kopecks,
SourceRecord.direction == opposite,
SourceRecord.occurred_on >= source.occurred_on - timedelta(days=1),
SourceRecord.occurred_on <= source.occurred_on + timedelta(days=1),
)
.all()
)
if len(transfer_matches) == 1:
counterpart = transfer_matches[0]
link = db.query(SourceRecordLink).filter_by(source_record_id=counterpart.id).one()
db.add(
DedupCandidate(
space_id=document.space_id,
source_record_id=source.id,
candidate_kind="transfer",
existing_source_record_id=counterpart.id,
existing_operation_id=link.operation_id,
confidence="possible",
)
)
pending = True
return pending
def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> str:
with session_scope(space_id) as db:
document = _document(db, space_id=space_id, document_id=document_id)
if document.status in {"confirmed", "deleted"}:
return document.status
document.status = "processing"
run = ParseRun(space_id=space_id, document_id=document.id, status="running")
db.add(run)
db.flush()
try:
if not all((document.dek_wrapped, document.dek_wrap_nonce, document.content_nonce)):
raise AppError(
"Document key material is missing",
code="IMPORT_DECRYPT_FAILED",
status_code=409,
)
assert document.dek_wrapped is not None
assert document.dek_wrap_nonce is not None
assert document.content_nonce is not None
raw = decrypt_file(
ciphertext=get_blob_store().get(document.object_key),
content_nonce=document.content_nonce,
dek_wrapped=document.dek_wrapped,
wrap_nonce=document.dek_wrap_nonce,
)
kind = magic_kind(raw)
if kind is None:
raise AppError(
"Unsupported document content", code="IMPORT_TYPE_INVALID", status_code=415
)
adapter = detect_adapter(raw, kind)
draft = adapter.parse(raw)
run.adapter_id = adapter.adapter_id
run.parser_version = draft.parser_version
run.draft = _draft_json(draft)
run.status = "succeeded"
document.adapter_id = adapter.adapter_id
document.account_fingerprint = draft.account_fingerprint
document.active_parse_run_id = run.id
document.reconciliation = {
"currency": draft.currency,
"opening_balance_kopecks": draft.opening_balance_kopecks,
"closing_balance_kopecks": draft.closing_balance_kopecks,
"full": draft.opening_balance_kopecks is not None
and draft.closing_balance_kopecks is not None,
"confirmed_without_full_reconciliation": False,
}
if document.reconciliation["full"]:
signed_total = sum(
row.amount_kopecks if row.direction == "in" else -row.amount_kopecks
for row in draft.rows
)
computed = int(draft.opening_balance_kopecks or 0) + signed_total
document.reconciliation["computed_closing_kopecks"] = computed
document.reconciliation["matched"] = computed == draft.closing_balance_kopecks
if draft.account_fingerprint and document.account_id is None:
matches = (
db.query(Account)
.filter_by(space_id=space_id, fingerprint=draft.account_fingerprint)
.all()
)
if len(matches) == 1:
document.account_id = matches[0].id
for row in draft.rows:
db.add(
_source_from_row(
space_id=space_id, document_id=document.id, run_id=run.id, row=row
)
)
db.flush()
has_candidates = _build_candidates(db, document=document, run=run)
critical = (
db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first()
)
document.needs_account_mapping = document.account_id is None
needs_manual_reconciliation = not document.reconciliation["full"]
document.status = (
"review_required"
if critical
or document.needs_account_mapping
or needs_manual_reconciliation
or has_candidates
or document.reconciliation.get("matched") is False
else "ready"
)
except AppError as exc:
run.status = "failed"
run.error_code = exc.code
document.status = "failed"
document.error_code = exc.code
except Exception:
run.status = "failed"
run.error_code = "IMPORT_PARSE_FAILED"
document.status = "failed"
document.error_code = "IMPORT_PARSE_FAILED"
return document.status
def list_documents(db: Session, *, space_id: uuid.UUID) -> list[StatementDocument]:
return (
db.query(StatementDocument)
.filter_by(space_id=space_id)
.order_by(StatementDocument.created_at.desc())
.all()
)
def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> dict:
document = _document(db, space_id=space_id, document_id=document_id)
records = []
if document.active_parse_run_id:
records = (
db.query(SourceRecord)
.filter_by(parse_run_id=document.active_parse_run_id)
.order_by(SourceRecord.created_at)
.all()
)
return {
"id": document.id,
"status": document.status,
"adapter_id": document.adapter_id,
"account_id": document.account_id,
"needs_account_mapping": document.needs_account_mapping,
"error_code": document.error_code,
"reconciliation": document.reconciliation,
"records": [
{
"id": item.id,
"external_id": item.external_id,
"occurred_on": item.occurred_on,
"posted_on": item.posted_on,
"amount_kopecks": item.amount_kopecks,
"direction": item.direction,
"description": item.description,
"critical": item.critical,
"warnings": item.field_warnings,
"review_decision": item.review_decision,
"duplicate_operation_id": item.duplicate_operation_id,
}
for item in records
],
}
def decide_source_record(
db: Session,
*,
space_id: uuid.UUID,
document_id: uuid.UUID,
source_record_id: uuid.UUID,
decision: str,
duplicate_operation_id: uuid.UUID | None,
user: User,
) -> None:
document = _document(db, space_id=space_id, document_id=document_id)
source = (
db.query(SourceRecord)
.filter_by(
id=source_record_id,
space_id=space_id,
parse_run_id=document.active_parse_run_id,
)
.one_or_none()
)
if source is None:
raise not_found("IMPORT_SOURCE_RECORD_NOT_FOUND", "Import row not found")
if decision not in {"create", "discard", "duplicate", "transfer"}:
raise conflict("IMPORT_DECISION_INVALID", "Unsupported row decision")
candidates = db.query(DedupCandidate).filter_by(source_record_id=source.id).all()
matching = [item for item in candidates if item.candidate_kind == decision]
if decision == "duplicate" and duplicate_operation_id is None and len(matching) == 1:
duplicate_operation_id = matching[0].existing_operation_id
if decision in {"duplicate", "transfer"} and len(matching) != 1:
raise conflict("IMPORT_CANDIDATE_AMBIGUOUS", "A unique matching candidate is required")
if decision == "duplicate":
operation = (
db.query(Operation)
.filter_by(id=duplicate_operation_id, space_id=space_id)
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Duplicate operation not found")
source.review_decision = decision
source.duplicate_operation_id = duplicate_operation_id
for candidate in candidates:
if candidate not in matching:
candidate.resolution = "is_new"
elif decision == "duplicate":
candidate.resolution = "is_duplicate"
else:
candidate.resolution = "accepted_transfer"
candidate.decided_by_user_id = user.id
if source.critical and decision in {"discard", "duplicate"}:
source.critical = False
unresolved = (
db.query(SourceRecord.id)
.filter_by(parse_run_id=document.active_parse_run_id, critical=True)
.first()
)
pending_candidate = (
db.query(DedupCandidate.id)
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
.filter(
SourceRecord.parse_run_id == document.active_parse_run_id,
DedupCandidate.resolution == "pending",
)
.first()
)
reconciliation = document.reconciliation or {}
reconciled = reconciliation.get("full") and reconciliation.get("matched") is not False
reconciled = reconciled or reconciliation.get("confirmed_without_full_reconciliation")
if not unresolved and not pending_candidate and document.account_id and reconciled:
document.status = "ready"
def set_review_confirmation(
db: Session,
*,
space_id: uuid.UUID,
document_id: uuid.UUID,
account_id: uuid.UUID,
confirmed_without_full_reconciliation: bool,
reason: str | None,
) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id)
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if confirmed_without_full_reconciliation and not reason:
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
document.account_id = account.id
document.needs_account_mapping = False
reconciliation = dict(document.reconciliation or {})
reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation
reconciliation["reason"] = reason
document.reconciliation = reconciliation
critical = (
db.query(SourceRecord.id)
.filter_by(parse_run_id=document.active_parse_run_id, critical=True)
.first()
)
pending_candidate = (
db.query(DedupCandidate.id)
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
.filter(
SourceRecord.parse_run_id == document.active_parse_run_id,
DedupCandidate.resolution == "pending",
)
.first()
)
if not critical and not pending_candidate and reconciliation.get("matched") is not False:
document.status = "ready"
return document
def confirm_document(
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id)
db.refresh(document, with_for_update=True)
if document.status == "confirmed":
return document
if document.status != "ready" or document.account_id is None:
raise conflict("IMPORT_NOT_READY", "Import review is not complete")
reconciliation = document.reconciliation or {}
if not reconciliation.get("full") and not reconciliation.get(
"confirmed_without_full_reconciliation"
):
raise conflict("IMPORT_RECONCILIATION_REQUIRED", "Reconciliation must be confirmed")
if reconciliation.get("full") and reconciliation.get("matched") is False:
raise conflict("IMPORT_RECONCILIATION_MISMATCH", "Statement arithmetic does not reconcile")
pending = (
db.query(DedupCandidate.id)
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
.filter(
SourceRecord.parse_run_id == document.active_parse_run_id,
DedupCandidate.resolution == "pending",
)
.first()
)
if pending:
raise conflict("IMPORT_DECISIONS_REQUIRED", "Duplicate or transfer decisions remain")
records = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).all()
if any(item.critical for item in records):
raise conflict("IMPORT_FIELDS_UNRESOLVED", "Critical source fields remain unresolved")
for source in records:
if source.review_decision == "discard":
continue
if source.review_decision == "transfer":
candidate = (
db.query(DedupCandidate)
.filter_by(
source_record_id=source.id,
candidate_kind="transfer",
resolution="accepted_transfer",
)
.one()
)
assert candidate.existing_source_record_id is not None
counterpart = db.get(SourceRecord, candidate.existing_source_record_id)
old_link = (
db.query(SourceRecordLink)
.filter_by(source_record_id=candidate.existing_source_record_id)
.one()
)
old_operation = db.get(Operation, old_link.operation_id)
if (
counterpart is None
or old_operation is None
or old_operation.account_id is None
or old_operation.type not in {"income", "expense"}
):
raise conflict(
"IMPORT_TRANSFER_TARGET_MISSING",
"Transfer counterpart is unavailable",
)
other_links = (
db.query(SourceRecordLink.id)
.filter(
SourceRecordLink.operation_id == old_operation.id,
SourceRecordLink.id != old_link.id,
)
.first()
)
if other_links:
raise conflict(
"IMPORT_TRANSFER_TARGET_AMBIGUOUS",
"Counterpart has multiple links",
)
ensure_period_open_for_date(db, space_id=space_id, day=old_operation.occurred_on)
from app.budgeting.models import ExpectedIncomeMatch
has_income_match = (
db.query(ExpectedIncomeMatch.id).filter_by(operation_id=old_operation.id).first()
)
has_refund = (
db.query(Operation.id)
.filter_by(parent_operation_id=old_operation.id, status="confirmed")
.first()
)
if has_income_match or has_refund:
raise conflict(
"IMPORT_TRANSFER_TARGET_HAS_DEPENDENCIES",
"Unlink matches and refunds before converting this operation to a transfer",
)
candidate.replacement_snapshot = {
"type": old_operation.type,
"account_id": str(old_operation.account_id),
"amount_kopecks": old_operation.amount_kopecks,
"occurred_on": old_operation.occurred_on.isoformat(),
"posted_on": old_operation.posted_on.isoformat()
if old_operation.posted_on
else None,
"category_id": str(old_operation.category_id)
if old_operation.category_id
else None,
"income_source_id": str(old_operation.income_source_id)
if old_operation.income_source_id
else None,
"payee_id": str(old_operation.payee_id) if old_operation.payee_id else None,
"description": old_operation.description,
"request_key": old_operation.request_key,
"link_kind": old_link.kind,
}
source_account_id = (
document.account_id if source.direction == "out" else old_operation.account_id
)
target_account_id = (
old_operation.account_id if source.direction == "out" else document.account_id
)
db.query(DedupCandidate).filter_by(existing_operation_id=old_operation.id).update(
{DedupCandidate.existing_operation_id: None}
)
db.delete(old_link)
db.delete(old_operation)
db.flush()
assert source.occurred_on is not None
operation = create_transfer(
db,
space_id=space_id,
user=user,
source_account_id=source_account_id,
target_account_id=target_account_id,
amount_kopecks=source.amount_kopecks,
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
request_key=f"import-transfer:{source.id}",
)
db.add_all(
[
SourceRecordLink(
space_id=space_id,
source_record_id=source.id,
operation_id=operation.id,
kind="transfer",
),
SourceRecordLink(
space_id=space_id,
source_record_id=counterpart.id,
operation_id=operation.id,
kind="transfer",
),
]
)
continue
if source.review_decision == "duplicate" and source.duplicate_operation_id:
duplicate_operation = (
db.query(Operation)
.filter_by(id=source.duplicate_operation_id, space_id=space_id)
.one_or_none()
)
if duplicate_operation is None:
raise conflict(
"IMPORT_DUPLICATE_TARGET_MISSING",
"Duplicate target no longer exists",
)
db.add(
SourceRecordLink(
space_id=space_id,
source_record_id=source.id,
operation_id=duplicate_operation.id,
kind="duplicate",
)
)
continue
existing_links = (
db.query(SourceRecordLink)
.join(SourceRecord, SourceRecord.id == SourceRecordLink.source_record_id)
.filter(
SourceRecord.space_id == space_id,
SourceRecord.external_id == source.external_id,
SourceRecord.external_id.is_not(None),
)
.all()
if source.external_id
else []
)
linked_operation = (
db.get(Operation, existing_links[0].operation_id) if len(existing_links) == 1 else None
)
kind = "duplicate"
if linked_operation is None:
assert source.occurred_on is not None
if source.direction == "in":
linked_operation = create_income(
db,
space_id=space_id,
user=user,
account_id=document.account_id,
amount_kopecks=source.amount_kopecks,
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
request_key=f"import:{source.id}",
)
else:
linked_operation = create_expense(
db,
space_id=space_id,
user=user,
account_id=document.account_id,
amount_kopecks=source.amount_kopecks,
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
request_key=f"import:{source.id}",
)
kind = "created"
db.add(
SourceRecordLink(
space_id=space_id,
source_record_id=source.id,
operation_id=linked_operation.id,
kind=kind,
)
)
document.status = "confirmed"
audit.record(
db,
event_type="import.confirmed",
space_id=space_id,
user_id=user.id,
detail={"document_id": str(document.id), "records": len(records)},
)
return document
def prepare_reparse(
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id)
if document.status == "confirmed":
raise conflict(
"IMPORT_REPARSE_CONFIRMED",
"Revert the confirmed import before reparsing its source",
)
if document.status == "deleted":
raise conflict("IMPORT_SOURCE_DELETED", "Deleted source files cannot be reparsed")
if document.status in {"queued", "processing"}:
return document
document.status = "queued"
document.error_code = None
audit.record(
db,
event_type="import.reparse_queued",
space_id=space_id,
user_id=user.id,
detail={"document_id": str(document.id)},
)
return document
def revert_document(
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
) -> StatementDocument:
from app.budgeting.models import BudgetPeriod
document = _document(db, space_id=space_id, document_id=document_id)
if document.status != "confirmed":
return document
links = (
db.query(SourceRecordLink)
.join(SourceRecord, SourceRecord.id == SourceRecordLink.source_record_id)
.filter(SourceRecord.parse_run_id == document.active_parse_run_id)
.all()
)
transfer_candidates = (
db.query(DedupCandidate)
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
.filter(
SourceRecord.parse_run_id == document.active_parse_run_id,
DedupCandidate.candidate_kind == "transfer",
DedupCandidate.resolution == "accepted_transfer",
)
.all()
)
created = [db.get(Operation, link.operation_id) for link in links if link.kind == "created"]
transfer_operations = [
db.get(Operation, link.operation_id) for link in links if link.kind == "transfer"
]
affected = {
(operation.occurred_on.year, operation.occurred_on.month)
for operation in created + transfer_operations
if operation is not None
}
for candidate in transfer_candidates:
candidate_snapshot = candidate.replacement_snapshot or {}
if candidate_snapshot.get("occurred_on"):
original_day = date.fromisoformat(candidate_snapshot["occurred_on"])
affected.add((original_day.year, original_day.month))
closed = (
db.query(BudgetPeriod)
.filter(
BudgetPeriod.space_id == space_id,
BudgetPeriod.status == "closed",
)
.all()
)
impacted = [item for item in closed if (item.year, item.month) in affected]
if impacted:
raise conflict(
"IMPORT_REVERT_REQUIRES_REOPEN",
"Affected budget periods must be reopened before reverting",
details={"period_ids": [str(item.id) for item in impacted]},
)
created_ids = [
operation.id for operation in created + transfer_operations if operation is not None
]
dependent_refund = (
db.query(Operation.id)
.filter(Operation.parent_operation_id.in_(created_ids), Operation.status == "confirmed")
.first()
if created_ids
else None
)
if dependent_refund:
raise conflict(
"IMPORT_REVERT_HAS_DEPENDENCIES",
"Void dependent refunds before reverting this import",
)
handled_link_ids: set[uuid.UUID] = set()
for candidate in transfer_candidates:
snapshot = candidate.replacement_snapshot
if not snapshot or candidate.existing_source_record_id is None:
raise conflict(
"IMPORT_REVERT_SNAPSHOT_MISSING",
"Transfer replacement metadata is unavailable",
)
current_link = (
db.query(SourceRecordLink)
.filter_by(source_record_id=candidate.source_record_id, kind="transfer")
.one()
)
transfer_operation = db.get(Operation, current_link.operation_id)
if transfer_operation is None:
raise conflict("IMPORT_REVERT_OPERATION_MISSING", "Transfer operation is unavailable")
transfer_links = (
db.query(SourceRecordLink)
.filter_by(operation_id=transfer_operation.id, kind="transfer")
.all()
)
handled_link_ids.update(link.id for link in transfer_links)
for transfer_link in transfer_links:
db.delete(transfer_link)
db.delete(transfer_operation)
db.flush()
account_id = uuid.UUID(snapshot["account_id"])
occurred_on = date.fromisoformat(snapshot["occurred_on"])
posted_on = date.fromisoformat(snapshot["posted_on"]) if snapshot["posted_on"] else None
if snapshot["type"] == "income":
restored = create_income(
db,
space_id=space_id,
user=user,
account_id=account_id,
amount_kopecks=int(snapshot["amount_kopecks"]),
occurred_on=occurred_on,
posted_on=posted_on,
income_source_id=(
uuid.UUID(snapshot["income_source_id"])
if snapshot["income_source_id"]
else None
),
description=snapshot["description"],
request_key=snapshot["request_key"],
)
else:
restored = create_expense(
db,
space_id=space_id,
user=user,
account_id=account_id,
amount_kopecks=int(snapshot["amount_kopecks"]),
occurred_on=occurred_on,
posted_on=posted_on,
category_id=(
uuid.UUID(snapshot["category_id"]) if snapshot["category_id"] else None
),
payee_id=uuid.UUID(snapshot["payee_id"]) if snapshot["payee_id"] else None,
description=snapshot["description"],
request_key=snapshot["request_key"],
)
db.add(
SourceRecordLink(
space_id=space_id,
source_record_id=candidate.existing_source_record_id,
operation_id=restored.id,
kind=snapshot["link_kind"],
)
)
for link in links:
if link.id in handled_link_ids:
continue
operation = db.get(Operation, link.operation_id)
db.delete(link)
if link.kind == "created" and operation is not None:
db.delete(operation)
document.status = "ready"
audit.record(
db,
event_type="import.reverted",
space_id=space_id,
user_id=user.id,
detail={"document_id": str(document.id)},
)
return document
def delete_source_file(
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id)
document.dek_wrapped = None
document.dek_wrap_nonce = None
document.content_nonce = None
document.status = "deleted"
audit.record(
db,
event_type="import.source_deleted",
space_id=space_id,
user_id=user.id,
detail={"document_id": str(document.id)},
)
return document
+22
View File
@@ -0,0 +1,22 @@
import uuid
from app.imports.celery_app import celery_app
@celery_app.task(name="imports.process_statement", bind=True, max_retries=3, default_retry_delay=10)
def process_statement_task(self, document_id: str, space_id: str) -> str:
from app.imports.service import process_statement_document
try:
status = process_statement_document(uuid.UUID(document_id), uuid.UUID(space_id))
if status == "failed":
raise RuntimeError("statement parsing failed")
except Exception as exc:
if self.request.retries < 3:
raise self.retry(exc=exc) from exc
raise
return document_id
def enqueue_process_statement(document_id: uuid.UUID, space_id: uuid.UUID) -> None:
process_statement_task.delay(str(document_id), str(space_id))
View File
+181
View File
@@ -0,0 +1,181 @@
import uuid
from datetime import date
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Date,
ForeignKey,
ForeignKeyConstraint,
Integer,
String,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
ACCOUNT_KINDS = ("cash", "debit", "credit")
ACCOUNT_STATUSES = ("active", "archived")
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
OPERATION_STATUSES = ("confirmed", "voided")
class Account(Base, PkMixin, TimestampMixin):
"""Financial account. Balance is always derived: opening + confirmed postings."""
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"),
CheckConstraint("status IN ('active', 'archived')", name="status_valid"),
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB")
opening_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
opened_on: Mapped[date] = mapped_column(Date, nullable=False)
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True)
fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class Operation(Base, PkMixin, TimestampMixin):
"""User financial fact. amount_kopecks is always positive; direction lives in postings."""
__tablename__ = "operations"
__table_args__ = (
CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"),
CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"),
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
CheckConstraint(
"NOT (type = 'transfer' AND counter_account_id IS NULL)",
name="transfer_requires_counter",
),
ForeignKeyConstraint(
["space_id", "account_id"],
["accounts.space_id", "accounts.id"],
name="fk_operations_account_tenant",
),
ForeignKeyConstraint(
["space_id", "counter_account_id"],
["accounts.space_id", "accounts.id"],
name="fk_operations_counter_account_tenant",
),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
name="fk_operations_category_tenant",
),
ForeignKeyConstraint(
["space_id", "income_source_id"],
["income_sources.space_id", "income_sources.id"],
name="fk_operations_income_source_tenant",
),
ForeignKeyConstraint(
["space_id", "payee_id"],
["payees.space_id", "payees.id"],
name="fk_operations_payee_tenant",
),
ForeignKeyConstraint(
["space_id", "parent_operation_id"],
["operations.space_id", "operations.id"],
name="fk_operations_parent_tenant",
),
UniqueConstraint("space_id", "request_key", name="uq_operations_space_request_key"),
UniqueConstraint("space_id", "id", name="uq_operations_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
type: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="confirmed")
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
counter_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
income_source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
payee_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
parent_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
occurred_on: Mapped[date] = mapped_column(Date, nullable=False, index=True)
posted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
request_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class Posting(Base, PkMixin, TimestampMixin):
"""Signed balance change of one account within one operation.
Sign convention (uniform for asset and credit accounts, from the account's
perspective): income and refunds post positive; expenses post negative;
transfers post negative on the source and positive on the destination.
Credit accounts normally carry a non-positive balance; debt = -balance.
"""
__tablename__ = "postings"
__table_args__ = (
CheckConstraint("amount_kopecks <> 0", name="amount_nonzero"),
ForeignKeyConstraint(
["space_id", "operation_id"],
["operations.space_id", "operations.id"],
ondelete="CASCADE",
name="fk_postings_operation_tenant",
),
ForeignKeyConstraint(
["space_id", "account_id"],
["accounts.space_id", "accounts.id"],
name="fk_postings_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_postings_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
class Category(Base, PkMixin, TimestampMixin):
"""Budget category. Kept in ledger module to avoid circular imports."""
__tablename__ = "categories"
__table_args__ = (
CheckConstraint("kind IN ('monthly', 'savings_envelope')", name="kind_valid"),
ForeignKeyConstraint(
["space_id", "linked_account_id"],
["accounts.space_id", "accounts.id"],
name="fk_categories_linked_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
system_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
name_override: Mapped[str | None] = mapped_column(String(200), nullable=True)
custom_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
linked_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+278
View File
@@ -0,0 +1,278 @@
import uuid
from datetime import date
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Header, Query
from pydantic import BaseModel, ConfigDict, Field
from app.categorization import service as categorization
from app.categorization.models import IncomeSource, Payee
from app.dependencies import Context, Csrf, Db
from app.ledger import service
from app.ledger.models import Category
router = APIRouter(tags=["ledger"])
class AccountBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str = Field(min_length=1, max_length=200)
kind: Literal["cash", "debit", "credit"] = Field(alias="type")
opened_on: date = Field(alias="openedOn")
opening_amount_kopecks: int = Field(default=0, alias="openingBalanceKopecks")
bank_code: str | None = Field(default=None, alias="bankCode", max_length=32)
masked_identifier: str | None = Field(default=None, alias="maskedNumber", max_length=32)
class OperationBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
operation_type: Literal["expense", "income", "transfer", "refund"] = Field(alias="kind")
amount_kopecks: int = Field(alias="amountKopecks", gt=0)
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
source_account_id: uuid.UUID | None = Field(default=None, alias="fromAccountId")
counter_account_id: uuid.UUID | None = Field(default=None, alias="toAccountId")
parent_operation_id: uuid.UUID | None = Field(default=None, alias="parentOperationId")
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
income_source_name: str | None = Field(default=None, alias="incomeSourceName")
payee_name: str | None = Field(default=None, alias="payeeName")
occurred_on: date = Field(alias="occurredOn")
posted_on: date | None = Field(default=None, alias="postedOn")
description: str | None = Field(default=None, max_length=500)
class VoidBody(BaseModel):
reason: str = Field(min_length=1, max_length=500)
class OperationPatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
def account_json(db: Db, account: Any) -> dict:
return {
"id": account.id,
"name": account.name,
"type": account.kind,
"currency": account.currency,
"openingBalanceKopecks": account.opening_amount_kopecks,
"openedOn": account.opened_on,
"bankCode": account.bank_code,
"maskedNumber": account.masked_identifier,
"status": account.status,
"balanceKopecks": service.account_balance(db, account),
"version": account.version,
}
def operation_json(db: Db, operation: Any) -> dict:
category = db.get(Category, operation.category_id) if operation.category_id else None
payee = db.get(Payee, operation.payee_id) if operation.payee_id else None
income_source = (
db.get(IncomeSource, operation.income_source_id) if operation.income_source_id else None
)
return {
"id": operation.id,
"kind": operation.type,
"status": operation.status,
"amountKopecks": operation.amount_kopecks,
"accountId": operation.account_id,
"counterpartyAccountId": operation.counter_account_id,
"categoryId": operation.category_id,
"categoryName": categorization.resolve_category_name(category) if category else None,
"categoryKey": category.system_key if category else None,
"payeeName": payee.name if payee else None,
"incomeSourceName": income_source.name if income_source else None,
"parentOperationId": operation.parent_operation_id,
"occurredOn": operation.occurred_on,
"postedOn": operation.posted_on,
"description": operation.description,
"voidReason": operation.void_reason,
"version": operation.version,
"createdAt": operation.created_at,
}
@router.get("/accounts")
def accounts(db: Db, context: Context) -> list[dict]:
return [account_json(db, item) for item in service.list_accounts(db, space_id=context.space.id)]
@router.post("/accounts", status_code=201)
def create_account(body: AccountBody, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.create_account(
db,
space_id=context.space.id,
user=context.user,
**body.model_dump(by_alias=False),
)
return account_json(db, account)
@router.post("/accounts/{account_id}/archive")
def archive_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.archive_account(
db, space_id=context.space.id, user=context.user, account_id=account_id
)
return account_json(db, account)
@router.patch("/accounts/{account_id}")
def patch_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.archive_account(
db, space_id=context.space.id, user=context.user, account_id=account_id
)
return account_json(db, account)
@router.delete("/accounts/{account_id}", status_code=204)
def delete_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> None:
service.delete_account(db, space_id=context.space.id, user=context.user, account_id=account_id)
@router.get("/operations")
def operations(
db: Db,
context: Context,
account_id: Annotated[uuid.UUID | None, Query(alias="accountId")] = None,
operation_type: Annotated[str | None, Query(alias="kind")] = None,
category_id: Annotated[uuid.UUID | None, Query(alias="categoryId")] = None,
status: str | None = None,
date_from: Annotated[date | None, Query(alias="occurredFrom")] = None,
date_to: Annotated[date | None, Query(alias="occurredTo")] = None,
search: str | None = None,
uncategorized: bool = False,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: str | None = None,
) -> dict:
offset = int(cursor) if cursor and cursor.isdigit() else 0
items, total = service.list_operations(
db,
space_id=context.space.id,
account_id=account_id,
operation_type=operation_type,
category_id=category_id,
status=status,
date_from=date_from,
date_to=date_to,
search=search,
uncategorized=uncategorized,
limit=limit,
offset=offset,
)
next_offset = offset + len(items)
return {
"items": [operation_json(db, item) for item in items],
"nextCursor": str(next_offset) if next_offset < total else None,
}
@router.post("/operations", status_code=201)
def create_operation(
body: OperationBody,
db: Db,
context: Context,
_csrf: Csrf,
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict:
if body.operation_type == "expense" and body.account_id:
payee_id = None
if body.payee_name:
payee_id = categorization.get_or_create_payee(
db, space_id=context.space.id, name=body.payee_name, alias=body.payee_name
).id
operation = service.create_expense(
db,
space_id=context.space.id,
user=context.user,
account_id=body.account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
category_id=body.category_id,
payee_id=payee_id,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "income" and body.account_id:
income_source_id = None
if body.income_source_name:
income_source_id = categorization.ensure_income_source(
db, space_id=context.space.id, name=body.income_source_name
).id
operation = service.create_income(
db,
space_id=context.space.id,
user=context.user,
account_id=body.account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
income_source_id=income_source_id,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "transfer" and body.source_account_id and body.counter_account_id:
operation = service.create_transfer(
db,
space_id=context.space.id,
user=context.user,
source_account_id=body.source_account_id,
target_account_id=body.counter_account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "refund" and body.parent_operation_id:
operation = service.create_refund(
db,
space_id=context.space.id,
user=context.user,
parent_operation_id=body.parent_operation_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
description=body.description,
request_key=idempotency_key,
)
else:
from app.errors import bad_request
raise bad_request("LEDGER_OPERATION_FIELDS_INVALID", "Required account fields are missing")
return operation_json(db, operation)
@router.post("/operations/{operation_id}/void")
def void_operation(
operation_id: uuid.UUID, body: VoidBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
operation = service.void_operation(
db,
space_id=context.space.id,
user=context.user,
operation_id=operation_id,
reason=body.reason,
)
return operation_json(db, operation)
@router.patch("/operations/{operation_id}")
def patch_operation(
operation_id: uuid.UUID,
body: OperationPatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
operation = service.update_operation(
db,
space_id=context.space.id,
user=context.user,
operation_id=operation_id,
category_id=body.category_id,
)
return operation_json(db, operation)
+552
View File
@@ -0,0 +1,552 @@
import uuid
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.categorization import service as categorization
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import ACCOUNT_KINDS, Account, Operation, Posting
MAX_PAGE_SIZE = 200
UNSET = object()
def to_kopecks(value: int | str | Decimal) -> int:
"""Convert a decimal ruble amount to integer kopecks. Floats are rejected upstream."""
if isinstance(value, int):
return value
try:
dec = Decimal(str(value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError) as exc:
raise bad_request("MONEY_INVALID_AMOUNT", f"Invalid amount: {value!r}") from exc
return int(dec.scaleb(2))
def get_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
return account
def get_operation(db: Session, *, space_id: uuid.UUID, operation_id: uuid.UUID) -> Operation:
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
return operation
def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.status != "active":
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
return account
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
if category_id is None:
return
from app.ledger.models import Category
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
if category is None:
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
def account_balance(db: Session, account: Account) -> int:
total = (
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
.join(Operation, Operation.id == Posting.operation_id)
.filter(
Posting.account_id == account.id,
Operation.status == "confirmed",
)
.scalar()
)
return account.opening_amount_kopecks + int(total or 0)
def create_account(
db: Session,
*,
space_id: uuid.UUID,
user: User,
name: str,
kind: str,
opened_on: date,
opening_amount_kopecks: int = 0,
bank_code: str | None = None,
masked_identifier: str | None = None,
) -> Account:
if kind not in ACCOUNT_KINDS:
raise bad_request("LEDGER_ACCOUNT_KIND_INVALID", "Unknown account kind")
account = Account(
space_id=space_id,
name=name,
kind=kind,
opened_on=opened_on,
opening_amount_kopecks=int(opening_amount_kopecks),
bank_code=bank_code,
masked_identifier=masked_identifier,
)
db.add(account)
db.flush()
audit.record(
db,
event_type="account.created",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id), "kind": kind},
)
return account
def _existing_by_request_key(
db: Session, *, space_id: uuid.UUID, request_key: str | None
) -> Operation | None:
if request_key is None:
return None
return db.query(Operation).filter_by(space_id=space_id, request_key=request_key).one_or_none()
def _apply_suggestion(
db: Session,
*,
space_id: uuid.UUID,
operation: Operation,
description: str | None,
) -> None:
"""Precedence: explicit choice (already set) > confirmed payee rule."""
if operation.category_id is not None or not description:
return
payee = categorization.find_payee_by_description(db, space_id=space_id, description=description)
if payee is None:
return
operation.payee_id = payee.id
rule = categorization.rule_category_for_payee(db, space_id=space_id, payee_id=payee.id)
if rule is not None:
operation.category_id = rule.category_id
def create_expense(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
category_id: uuid.UUID | None = None,
payee_id: uuid.UUID | None = None,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_validate_category(db, space_id=space_id, category_id=category_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
space_id=space_id,
type="expense",
amount_kopecks=amount_kopecks,
account_id=account.id,
category_id=category_id,
payee_id=payee_id,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
_apply_suggestion(db, space_id=space_id, operation=operation, description=description)
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=-amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "expense"},
)
return operation
def create_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
income_source_id: uuid.UUID | None = None,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
if income_source_id is not None:
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
space_id=space_id,
type="income",
amount_kopecks=amount_kopecks,
account_id=account.id,
income_source_id=income_source_id,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "income"},
)
return operation
def create_transfer(
db: Session,
*,
space_id: uuid.UUID,
user: User,
source_account_id: uuid.UUID,
target_account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
if source_account_id == target_account_id:
raise bad_request("LEDGER_TRANSFER_SAME_ACCOUNT", "Transfer requires two distinct accounts")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
source = _require_active_account(db, space_id=space_id, account_id=source_account_id)
target = _require_active_account(db, space_id=space_id, account_id=target_account_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
space_id=space_id,
type="transfer",
amount_kopecks=amount_kopecks,
account_id=source.id,
counter_account_id=target.id,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=source.id,
amount_kopecks=-amount_kopecks,
)
)
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=target.id,
amount_kopecks=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "transfer"},
)
return operation
def refunded_kopecks(db: Session, *, parent_operation_id: uuid.UUID) -> int:
total = (
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
.filter(
Operation.parent_operation_id == parent_operation_id,
Operation.type == "refund",
Operation.status == "confirmed",
)
.scalar()
)
return int(total or 0)
def create_refund(
db: Session,
*,
space_id: uuid.UUID,
user: User,
parent_operation_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
description: str | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
parent = (
db.query(Operation)
.filter_by(id=parent_operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if parent is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if parent.type != "expense" or parent.status != "confirmed":
raise conflict("LEDGER_REFUND_PARENT_INVALID", "Refund parent must be a confirmed expense")
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
remaining = parent.amount_kopecks - refunded_kopecks(db, parent_operation_id=parent.id)
if amount_kopecks > remaining:
raise conflict(
"LEDGER_REFUND_EXCEEDS_PARENT",
"Refund exceeds the unrefunded remainder of the original expense",
details={"remaining_kopecks": remaining},
)
operation = Operation(
space_id=space_id,
type="refund",
amount_kopecks=amount_kopecks,
account_id=parent.account_id,
parent_operation_id=parent.id,
category_id=parent.category_id,
occurred_on=occurred_on,
description=description or parent.description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=parent.account_id,
amount_kopecks=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "refund"},
)
return operation
def ensure_period_open_for_date(db: Session, *, space_id: uuid.UUID, day: date) -> None:
from app.budgeting.models import BudgetPeriod
period = (
db.query(BudgetPeriod)
.filter(
BudgetPeriod.space_id == space_id,
BudgetPeriod.year == day.year,
BudgetPeriod.month == day.month,
)
.with_for_update()
.one_or_none()
)
if period is not None and period.status == "closed":
raise conflict(
"BUDGET_PERIOD_CLOSED",
"Period is closed; reopen it before changing its operations",
details={"period_id": str(period.id), "year": period.year, "month": period.month},
)
def void_operation(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID, reason: str
) -> Operation:
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
if operation.status == "voided":
return operation
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
operation.status = "voided"
operation.void_reason = reason
audit.record(
db,
event_type="operation.voided",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
def update_operation(
db: Session,
*,
space_id: uuid.UUID,
user: User,
operation_id: uuid.UUID,
category_id: Any = UNSET,
payee_id: Any = UNSET,
description: Any = UNSET,
) -> Operation:
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
if operation.status == "voided":
raise conflict("LEDGER_OPERATION_VOIDED", "Voided operations cannot be edited")
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
if category_id is not UNSET:
if category_id is not None:
_validate_category(db, space_id=space_id, category_id=category_id)
operation.category_id = category_id
if payee_id is not UNSET:
operation.payee_id = payee_id
if description is not UNSET:
operation.description = description
audit.record(
db,
event_type="operation.updated",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
def archive_account(
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.status == "archived":
return account
if account_balance(db, account) != 0:
raise conflict(
"LEDGER_ACCOUNT_BALANCE_NOT_ZERO",
"Only accounts with zero balance can be archived",
)
account.status = "archived"
audit.record(
db,
event_type="account.archived",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id)},
)
return account
def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None:
account = get_account(db, space_id=space_id, account_id=account_id)
used = (
db.query(Operation.id)
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
.first()
)
if used is not None:
raise conflict(
"LEDGER_ACCOUNT_IN_USE",
"Only never-used accounts can be deleted; archive instead",
)
db.delete(account)
audit.record(
db,
event_type="account.deleted",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id)},
)
def list_operations(
db: Session,
*,
space_id: uuid.UUID,
account_id: uuid.UUID | None = None,
operation_type: str | None = None,
category_id: uuid.UUID | None = None,
status: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
search: str | None = None,
uncategorized: bool = False,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Operation], int]:
query = db.query(Operation).filter(Operation.space_id == space_id)
if account_id is not None:
query = query.filter(
(Operation.account_id == account_id) | (Operation.counter_account_id == account_id)
)
if operation_type is not None:
query = query.filter(Operation.type == operation_type)
if category_id is not None:
query = query.filter(Operation.category_id == category_id)
if status is not None:
query = query.filter(Operation.status == status)
if date_from is not None:
query = query.filter(Operation.occurred_on >= date_from)
if date_to is not None:
query = query.filter(Operation.occurred_on <= date_to)
if search:
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.filter(Operation.description.ilike(f"%{escaped}%", escape="\\"))
if uncategorized:
query = query.filter(Operation.type == "expense", Operation.category_id.is_(None))
total = query.count()
items = (
query.order_by(Operation.occurred_on.desc(), Operation.created_at.desc())
.offset(offset)
.limit(min(limit, MAX_PAGE_SIZE))
.all()
)
return items, total
def list_accounts(db: Session, *, space_id: uuid.UUID) -> list[Account]:
return db.query(Account).filter_by(space_id=space_id).order_by(Account.created_at).all()
+79
View File
@@ -0,0 +1,79 @@
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm.exc import StaleDataError
from app.analytics.router import router as analytics_router
from app.budgeting.router import router as budgeting_router
from app.db.session import get_engine
from app.errors import AppError
from app.exports.router import router as exports_router
from app.identity.router import router as identity_router
from app.imports.router import router as imports_router
from app.ledger.router import router as ledger_router
from app.spaces.router import router as spaces_router
app = FastAPI(
title="Finance Tracker API",
version="0.1.0",
docs_url="/api/docs",
openapi_url="/api/v1/openapi.json",
)
@app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": exc.message, "details": exc.details}},
)
@app.exception_handler(StaleDataError)
async def stale_data_handler(_request: Request, _exc: StaleDataError) -> JSONResponse:
return JSONResponse(
status_code=409,
content={"error": {"code": "VERSION_CONFLICT", "message": "Resource changed"}},
)
@app.middleware("http")
async def security_headers(request: Request, call_next: Callable[[Request], Awaitable]):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
return response
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/healthz", include_in_schema=False)
def healthz() -> dict:
return health()
@app.get("/ready")
def readiness() -> dict:
with get_engine().connect() as connection:
connection.execute(text("select 1"))
return {"status": "ready"}
for api_router in (
identity_router,
spaces_router,
ledger_router,
budgeting_router,
imports_router,
analytics_router,
exports_router,
):
app.include_router(api_router, prefix="/api/v1")
+42
View File
@@ -0,0 +1,42 @@
import hashlib
import threading
import time
from collections import defaultdict, deque
from redis import Redis
from redis.exceptions import RedisError
from app.config import get_settings
_lock = threading.Lock()
_hits: dict[str, deque[float]] = defaultdict(deque)
def allow(key: str, limit: int, window_seconds: float) -> bool:
"""Distributed fixed-window limiter with a local fail-safe fallback."""
digest = hashlib.sha256(key.encode()).hexdigest()
window = int(time.time() // window_seconds)
try:
client = Redis.from_url(
get_settings().redis_url,
socket_connect_timeout=0.2,
socket_timeout=0.2,
)
redis_key = f"rate:{digest}:{window}"
with client.pipeline() as pipeline:
pipeline.incr(redis_key)
pipeline.expire(redis_key, int(window_seconds) + 1)
count, _ = pipeline.execute()
return int(count) <= limit
except RedisError:
pass
now = time.monotonic()
with _lock:
bucket = _hits[digest]
while bucket and bucket[0] <= now - window_seconds:
bucket.popleft()
if len(bucket) >= limit:
return False
bucket.append(now)
return True
+30
View File
@@ -0,0 +1,30 @@
import hashlib
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
_password_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _password_hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _password_hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def new_token() -> str:
return secrets.token_urlsafe(32)
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def constant_time_equals(left: str, right: str) -> bool:
return secrets.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
View File
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String, UniqueConstraint, Uuid
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
class FinancialSpace(Base, PkMixin, TimestampMixin):
__tablename__ = "financial_spaces"
name: Mapped[str] = mapped_column(String(200), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
deletion_scheduled_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
class Membership(Base, PkMixin, TimestampMixin):
__tablename__ = "memberships"
__table_args__ = (UniqueConstraint("space_id", "user_id", name="uq_memberships_space_user"),)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
+91
View File
@@ -0,0 +1,91 @@
from typing import Annotated
from fastapi import APIRouter, Header
from pydantic import BaseModel, EmailStr, Field
from app.dependencies import Context, Csrf, CurrentSession, Db
from app.errors import bad_request, forbidden
from app.exports import service as exports_service
from app.identity import service as identity_service
from app.identity.mailer import send_token_email
from app.security import constant_time_equals
from app.spaces.service import list_spaces_for_user, require_owner
router = APIRouter(prefix="/spaces/current", tags=["spaces"])
class DeletionBody(BaseModel):
confirmation_phrase: str = Field(alias="confirmationPhrase")
class MemberInviteBody(BaseModel):
email: EmailStr
@router.get("")
def current(context: Context) -> dict:
return {
"id": context.space.id,
"name": context.space.name,
"status": context.space.status,
"role": context.membership.role,
}
@router.post("/invitations", status_code=201)
def invite_member(body: MemberInviteBody, db: Db, context: Context, _csrf: Csrf) -> dict:
require_owner(context.membership)
invitation, token = identity_service.create_invitation(
db,
email=str(body.email),
invited_by_user_id=context.user.id,
space_id=context.space.id,
)
db.commit()
send_token_email(recipient=invitation.email, token=token, kind="invite")
return {"id": invitation.id, "expiresAt": invitation.expires_at}
@router.post("/deletion", status_code=202)
def schedule_deletion(body: DeletionBody, db: Db, context: Context, _csrf: Csrf) -> dict:
if body.confirmation_phrase != "DELETE":
raise bad_request("SPACE_DELETION_CONFIRMATION_INVALID", "Confirmation phrase is invalid")
require_owner(context.membership)
scheduled_at = exports_service.schedule_deletion(db, space=context.space)
return {
"status": "pending_deletion",
"scheduledAt": scheduled_at,
"cancelsUntil": scheduled_at,
}
@router.get("/deletion")
def deletion_status(db: Db, auth: CurrentSession) -> dict:
user, _ = auth
spaces = list_spaces_for_user(db, user=user)
if not spaces:
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
space, _membership = spaces[0]
return {
"status": "pending_deletion" if space.status == "blocked" else "active",
"scheduledAt": space.deletion_scheduled_at,
"cancelsUntil": space.deletion_scheduled_at,
}
@router.delete("/deletion")
def cancel_deletion(
db: Db,
auth: CurrentSession,
csrf_header: Annotated[str | None, Header(alias="X-CSRF-Token")] = None,
) -> dict:
user, session = auth
if not csrf_header or not constant_time_equals(session.csrf_token, csrf_header):
raise forbidden("IDENTITY_CSRF_INVALID", "CSRF token is missing or invalid")
spaces = list_spaces_for_user(db, user=user)
if not spaces:
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
space, membership = spaces[0]
require_owner(membership)
exports_service.cancel_deletion(db, space=space)
return {"status": "active", "scheduledAt": None, "cancelsUntil": None}
+57
View File
@@ -0,0 +1,57 @@
import uuid
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.db.session import set_space_context
from app.errors import conflict, forbidden, not_found
from app.identity.models import User
from app.spaces.models import FinancialSpace, Membership
def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
space = FinancialSpace(name=name)
db.add(space)
db.flush()
set_space_context(db, space.id)
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
return space
def get_membership(db: Session, *, space_id: uuid.UUID, user_id: uuid.UUID) -> Membership:
membership = db.query(Membership).filter_by(space_id=space_id, user_id=user_id).first()
if membership is None:
raise not_found("SPACE_NOT_FOUND", "Financial space not found")
return membership
def get_space_for_member(
db: Session, *, space_id: uuid.UUID, user: User
) -> tuple[FinancialSpace, Membership]:
space = db.get(FinancialSpace, space_id)
if space is None:
raise not_found("SPACE_NOT_FOUND", "Financial space not found")
membership = get_membership(db, space_id=space_id, user_id=user.id)
if space.status == "blocked":
raise conflict(
"SPACE_BLOCKED",
"Financial space is blocked pending deletion",
)
return space, membership
def require_owner(membership: Membership) -> None:
if membership.role != "owner":
raise forbidden("SPACE_OWNER_REQUIRED", "Owner role required")
def list_spaces_for_user(db: Session, *, user: User) -> list[tuple[FinancialSpace, Membership]]:
rows = (
db.query(FinancialSpace, Membership)
.join(Membership, Membership.space_id == FinancialSpace.id)
.filter(Membership.user_id == user.id)
.order_by(FinancialSpace.created_at)
.all()
)
return [(space, membership) for space, membership in rows]
+5
View File
@@ -0,0 +1,5 @@
from app.imports.celery_app import celery_app
celery = celery_app
__all__ = ["celery", "celery_app"]
+59
View File
@@ -0,0 +1,59 @@
[project]
name = "finance-backend"
version = "0.1.0"
description = "FastAPI backend for the personal zero-based budget tracker MVP"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"email-validator>=2.1",
"sqlalchemy>=2.0.30",
"psycopg[binary]>=3.2",
"alembic>=1.13",
"celery[redis]>=5.4",
"redis>=5.0",
"redis>=5.0",
"argon2-cffi>=23.1",
"cryptography>=42.0",
"openpyxl>=3.1",
"defusedxml>=0.7",
"pdfplumber>=0.11",
"python-multipart>=0.0.9",
"boto3>=1.35",
]
[dependency-groups]
dev = [
"pytest>=8.2",
"httpx>=0.27",
"ruff>=0.5",
"mypy>=1.10",
"hypothesis>=6.112",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["B008"]
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
disable_error_code = ["import-untyped"]
exclude = ["alembic/", "tests/"]
+60
View File
@@ -0,0 +1,60 @@
import os
from collections.abc import Iterator
os.environ.setdefault("ADMIN_BOOTSTRAP_TOKEN", "test-admin-token")
os.environ.setdefault("COOKIE_SECURE", "false")
os.environ.setdefault("IMPORTS_KEK_B64", "MDAxMTIyMzM0NDU1NjY3Nzg4ODlhYWJiY2NkZGVlZg==")
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db import models as _models # noqa: F401
from app.db.base import Base
from app.db.session import get_db
from app.main import app
@pytest.fixture
def db_factory() -> Iterator[sessionmaker[Session]]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(engine, "connect")
def foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
yield factory
Base.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture
def db(db_factory: sessionmaker[Session]) -> Iterator[Session]:
with db_factory() as session:
yield session
session.rollback()
@pytest.fixture
def client(db_factory: sessionmaker[Session]) -> Iterator[TestClient]:
def override_db() -> Iterator[Session]:
with db_factory() as session:
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
app.dependency_overrides[get_db] = override_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
+45
View File
@@ -0,0 +1,45 @@
from fastapi.testclient import TestClient
def test_invite_login_and_authenticated_space(client: TestClient) -> None:
invite = client.post(
"/api/v1/admin/invitations",
headers={"X-Admin-Token": "test-admin-token"},
json={"email": "Person@Example.com"},
)
assert invite.status_code == 201, invite.text
accepted = client.post(
"/api/v1/auth/invitations/accept",
json={
"token": invite.json()["token"],
"password": "a-long-password-for-tests",
"fullName": "Test Person",
"locale": "en",
},
)
assert accepted.status_code == 201, accepted.text
assert accepted.json()["email"] == "person@example.com"
assert client.get("/api/v1/spaces/current").status_code == 200
csrf = client.cookies["csrf_token"]
account = client.post(
"/api/v1/accounts",
headers={"X-CSRF-Token": csrf},
json={
"name": "Wallet",
"type": "cash",
"openedOn": "2026-09-01",
"openingBalanceKopecks": 12_500,
},
)
assert account.status_code == 201, account.text
assert account.json()["balanceKopecks"] == 12_500
def test_openapi_contains_critical_paths(client: TestClient) -> None:
schema = client.get("/api/v1/openapi.json").json()
paths = schema["paths"]
assert "/api/v1/operations" in paths
assert "/api/v1/imports/{document_id}/confirm" in paths
assert "/api/v1/budget/close" in paths
assert "/api/v1/analytics/balances" in paths
+100
View File
@@ -0,0 +1,100 @@
from datetime import date
import pytest
from sqlalchemy.orm import Session
from app.budgeting.models import PeriodClosure
from app.budgeting.service import (
close_period,
compute_period_state,
copy_previous_month,
create_category,
get_or_create_period,
reopen_period,
set_assignments,
)
from app.errors import AppError
from app.identity.models import User
from app.ledger.service import create_account, create_expense
from app.spaces.service import create_space
def seed(db: Session):
user = User(email="budget@example.test", password_hash="unused-in-domain-tests")
db.add(user)
db.flush()
return user, create_space(db, user=user, name="Budget")
def test_uncategorized_expense_blocks_close(db: Session) -> None:
user, space = seed(db)
account = create_account(
db,
space_id=space.id,
user=user,
name="Cash",
kind="cash",
opened_on=date(2026, 3, 1),
opening_amount_kopecks=10_000,
)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=1_000,
occurred_on=date(2026, 3, 2),
)
period = get_or_create_period(db, space_id=space.id, year=2026, month=3)
with pytest.raises(AppError) as exc:
close_period(db, space_id=space.id, user=user, period=period)
assert exc.value.code == "BUDGET_CLOSE_BLOCKED"
def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
from app.budgeting.service import add_expected_income
user, space = seed(db)
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Food")
january = get_or_create_period(db, space_id=space.id, year=2026, month=1)
february = get_or_create_period(db, space_id=space.id, year=2026, month=2)
set_assignments(
db,
space_id=space.id,
user=user,
period=january,
items=[{"category_id": category.id, "planned_kopecks": 30_000}],
)
add_expected_income(
db,
space_id=space.id,
user=user,
period=january,
expected_on=date(2026, 1, 31),
amount_kopecks=100_000,
source_name="Salary",
)
result = copy_previous_month(db, space_id=space.id, user=user, period=february)
assert result == {"assignments_copied": 1, "expected_incomes_copied": 1}
state = compute_period_state(db, space_id=space.id, period=february)
assert state["expected_incomes"][0]["expected_on"] == "2026-02-28"
def test_reopen_preserves_immutable_closure_history(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=4)
close_period(db, space_id=space.id, user=user, period=period)
reopen_period(db, space_id=space.id, user=user, period=period)
close_period(db, space_id=space.id, user=user, period=period)
assert db.query(PeriodClosure).filter_by(period_id=period.id).count() == 2
def test_periods_must_close_in_calendar_order(db: Session) -> None:
user, space = seed(db)
august = get_or_create_period(db, space_id=space.id, year=2026, month=8)
september = get_or_create_period(db, space_id=space.id, year=2026, month=9)
with pytest.raises(AppError) as exc:
close_period(db, space_id=space.id, user=user, period=september)
assert exc.value.code == "BUDGET_CLOSE_OUT_OF_ORDER"
close_period(db, space_id=space.id, user=user, period=august)
close_period(db, space_id=space.id, user=user, period=september)
+283
View File
@@ -0,0 +1,283 @@
import uuid
from contextlib import contextmanager
from datetime import date
import pytest
from sqlalchemy.orm import Session
from app.errors import AppError
from app.identity.models import User
from app.imports import service
from app.imports.adapters.base import SourceRow
from app.imports.models import (
DedupCandidate,
ParseRun,
SourceRecord,
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Operation
from app.ledger.service import create_account
from app.spaces.service import create_space
def seed(db: Session):
user = User(email=f"{uuid.uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="Imports")
account = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 1, 1),
)
return user, space, account
def ready_document(db: Session):
user, space, account = seed(db)
document = StatementDocument(
space_id=space.id,
account_id=account.id,
original_filename="synthetic.xlsx",
size_bytes=100,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
status="ready",
reconciliation={"full": True, "matched": True},
uploaded_by_user_id=user.id,
)
db.add(document)
db.flush()
run = ParseRun(
space_id=space.id,
document_id=document.id,
adapter_id="alfa_debit_xlsx",
status="succeeded",
)
db.add(run)
db.flush()
document.active_parse_run_id = run.id
source = SourceRecord(
space_id=space.id,
document_id=document.id,
parse_run_id=run.id,
occurred_on=date(2026, 1, 5),
amount_kopecks=12_345,
direction="out",
row_ref="sheet0:row2",
record_fp=uuid.uuid4().hex,
)
db.add(source)
db.flush()
return user, space, document
def test_confirm_and_revert_are_idempotent(db: Session) -> None:
user, space, document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 1
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
user, space, document = ready_document(db)
document.reconciliation = {"full": True, "matched": False}
with pytest.raises(AppError) as exc:
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert exc.value.code == "IMPORT_RECONCILIATION_MISMATCH"
def test_confirmed_import_must_be_reverted_before_reparse(db: Session) -> None:
user, space, document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
with pytest.raises(AppError) as exc:
service.prepare_reparse(db, space_id=space.id, document_id=document.id, user=user)
assert exc.value.code == "IMPORT_REPARSE_CONFIRMED"
def test_invalid_direction_is_critical() -> None:
source = service._source_from_row(
space_id=uuid.uuid4(),
document_id=uuid.uuid4(),
run_id=uuid.uuid4(),
row=SourceRow(
external_id=None,
occurred_on=date(2026, 1, 1),
posted_on=None,
amount_kopecks=100,
direction="sideways",
description=None,
mcc=None,
bank_category=None,
location=None,
row_ref="row1",
),
)
assert source.critical is True
assert source.field_warnings == {"codes": ["direction_invalid"]}
def test_unexpected_parser_failure_reaches_terminal_state(db: Session, monkeypatch) -> None:
_user, space, account = seed(db)
document = StatementDocument(
space_id=space.id,
account_id=account.id,
original_filename="synthetic.pdf",
size_bytes=10,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
dek_wrapped=b"key",
dek_wrap_nonce=b"nonce",
content_nonce=b"nonce",
status="queued",
)
db.add(document)
db.flush()
@contextmanager
def existing_session(_space_id):
yield db
class BrokenStore:
def get(self, _key):
raise RuntimeError("storage unavailable")
monkeypatch.setattr(service, "session_scope", existing_session)
monkeypatch.setattr(service, "get_blob_store", lambda: BrokenStore())
service.process_statement_document(document.id, space.id)
assert document.status == "failed"
assert document.error_code == "IMPORT_PARSE_FAILED"
def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Session) -> None:
user, space, first_document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
first_source = (
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
)
second = StatementDocument(
space_id=space.id,
account_id=first_document.account_id,
original_filename="second.xlsx",
size_bytes=100,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
status="review_required",
reconciliation={"full": True, "matched": True},
)
db.add(second)
db.flush()
run = ParseRun(
space_id=space.id,
document_id=second.id,
adapter_id="alfa_debit_xlsx",
status="succeeded",
)
db.add(run)
db.flush()
second.active_parse_run_id = run.id
duplicate_source = SourceRecord(
space_id=space.id,
document_id=second.id,
parse_run_id=run.id,
occurred_on=first_source.occurred_on,
amount_kopecks=first_source.amount_kopecks,
direction=first_source.direction,
row_ref="sheet0:row9",
record_fp=first_source.record_fp,
)
db.add(duplicate_source)
db.flush()
assert service._build_candidates(db, document=second, run=run)
candidate = db.query(DedupCandidate).filter_by(source_record_id=duplicate_source.id).one()
assert candidate.candidate_kind == "duplicate"
service.decide_source_record(
db,
space_id=space.id,
document_id=second.id,
source_record_id=duplicate_source.id,
decision="duplicate",
duplicate_operation_id=None,
user=user,
)
assert second.status == "ready"
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
def test_transfer_merge_revert_restores_counterpart_operation(db: Session) -> None:
user, space, first_document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
first_source = (
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
)
target = create_account(
db,
space_id=space.id,
user=user,
name="Second debit",
kind="debit",
opened_on=date(2026, 1, 1),
)
second = StatementDocument(
space_id=space.id,
account_id=target.id,
original_filename="target.xlsx",
size_bytes=100,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
status="review_required",
reconciliation={"full": True, "matched": True},
)
db.add(second)
db.flush()
run = ParseRun(
space_id=space.id,
document_id=second.id,
adapter_id="alfa_debit_xlsx",
status="succeeded",
)
db.add(run)
db.flush()
second.active_parse_run_id = run.id
incoming = SourceRecord(
space_id=space.id,
document_id=second.id,
parse_run_id=run.id,
occurred_on=first_source.occurred_on,
amount_kopecks=first_source.amount_kopecks,
direction="in",
row_ref="sheet0:row10",
record_fp=uuid.uuid4().hex,
)
db.add(incoming)
db.flush()
assert service._build_candidates(db, document=second, run=run)
candidate = db.query(DedupCandidate).filter_by(source_record_id=incoming.id).one()
assert candidate.candidate_kind == "transfer"
service.decide_source_record(
db,
space_id=space.id,
document_id=second.id,
source_record_id=incoming.id,
decision="transfer",
duplicate_operation_id=None,
user=user,
)
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 1
service.revert_document(db, space_id=space.id, document_id=second.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 0
assert db.query(Operation).filter_by(space_id=space.id, type="expense").count() == 1
+155
View File
@@ -0,0 +1,155 @@
from datetime import date
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from sqlalchemy.orm import Session
from app.errors import AppError
from app.identity.models import User
from app.ledger.models import Posting
from app.ledger.service import (
account_balance,
create_account,
create_expense,
create_income,
create_refund,
create_transfer,
get_account,
)
from app.spaces.service import create_space
def seed(db: Session):
user = User(email="owner@example.test", password_hash="unused-in-domain-tests")
db.add(user)
db.flush()
space = create_space(db, user=user, name="Personal")
return user, space
@given(amount=st.integers(min_value=1, max_value=10**12))
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_transfer_postings_conserve_money(db: Session, amount: int) -> None:
db.rollback()
user, space = seed(db)
source = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 1, 1),
opening_amount_kopecks=amount,
)
target = create_account(
db,
space_id=space.id,
user=user,
name="Cash",
kind="cash",
opened_on=date(2026, 1, 1),
)
operation = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=source.id,
target_account_id=target.id,
amount_kopecks=amount,
occurred_on=date(2026, 1, 2),
)
db.flush()
postings = db.query(Posting).filter_by(operation_id=operation.id).all()
assert len(postings) == 2
assert sum(item.amount_kopecks for item in postings) == 0
assert account_balance(db, source) == 0
assert account_balance(db, target) == amount
db.rollback()
def test_credit_expense_is_negative_balance_and_refund_is_capped(db: Session) -> None:
user, space = seed(db)
card = create_account(
db,
space_id=space.id,
user=user,
name="Credit",
kind="credit",
opened_on=date(2026, 2, 1),
)
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=card.id,
amount_kopecks=12_345,
occurred_on=date(2026, 2, 2),
)
assert account_balance(db, card) == -12_345
create_refund(
db,
space_id=space.id,
user=user,
parent_operation_id=expense.id,
amount_kopecks=2_345,
occurred_on=date(2026, 2, 3),
)
assert account_balance(db, card) == -10_000
with pytest.raises(AppError, match="Refund exceeds"):
create_refund(
db,
space_id=space.id,
user=user,
parent_operation_id=expense.id,
amount_kopecks=10_001,
occurred_on=date(2026, 2, 4),
)
def test_cross_space_lookup_fails_closed(db: Session) -> None:
user, first = seed(db)
other = create_space(db, user=user, name="Other")
account = create_account(
db,
space_id=first.id,
user=user,
name="Cash",
kind="cash",
opened_on=date(2026, 1, 1),
)
with pytest.raises(AppError) as exc:
get_account(db, space_id=other.id, account_id=account.id)
assert exc.value.code == "LEDGER_ACCOUNT_NOT_FOUND"
def test_idempotency_key_prevents_duplicate_income(db: Session) -> None:
user, space = seed(db)
account = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 1, 1),
)
first = create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=50_000,
occurred_on=date(2026, 1, 5),
request_key="retry-1",
)
second = create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=50_000,
occurred_on=date(2026, 1, 5),
request_key="retry-1",
)
assert first.id == second.id
assert account_balance(db, account) == 50_000
+52
View File
@@ -0,0 +1,52 @@
import os
import uuid
from datetime import date
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import DBAPIError
from sqlalchemy.orm import Session
from app.db.session import set_space_context
from app.imports.blob import get_blob_store
from app.ledger.models import Account
from app.spaces.models import FinancialSpace
@pytest.mark.skipif(not os.getenv("RLS_DATABASE_URL"), reason="requires restricted PostgreSQL role")
def test_postgres_rls_requires_matching_transaction_context() -> None:
engine = create_engine(os.environ["RLS_DATABASE_URL"])
with Session(engine) as db:
space = FinancialSpace(name="RLS integration")
db.add(space)
db.commit()
space_id = space.id
db.add(Account(space_id=space_id, name="Blocked", kind="cash", opened_on=date.today()))
with pytest.raises(DBAPIError):
db.commit()
db.rollback()
set_space_context(db, space_id)
db.add(Account(space_id=space_id, name="Allowed", kind="cash", opened_on=date.today()))
db.commit()
set_space_context(db, uuid.uuid4())
assert db.query(Account).count() == 0
db.rollback()
set_space_context(db, space_id)
persisted_space = db.get(FinancialSpace, space_id)
assert persisted_space is not None
db.delete(persisted_space)
db.commit()
engine.dispose()
@pytest.mark.skipif(not os.getenv("S3_ENDPOINT_URL"), reason="requires S3-compatible storage")
def test_s3_blob_round_trip() -> None:
store = get_blob_store()
key = uuid.uuid4().hex
payload = b"synthetic statement payload"
store.put(key, payload)
assert store.get(key) == payload
store.delete(key)
+2082
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
# ADR 0001: Financial space ownership and RLS
- **Status:** Accepted
- **Date:** 2026-09-11
- **Scope:** tenancy model, data isolation, authorization boundary
## Context
Every financial fact (accounts, operations, postings, budgets, categories, payees, imports) belongs to exactly one financial space. All members of a space see all of its data; there is no per-user row visibility in MVP. Support staff must not be able to browse financial content. PostgreSQL is the only datastore, with pooled connections shared by API processes and Celery workers, so a leaked tenant context is a critical failure mode.
## Decision
- `financial_spaces` is the single ownership boundary. Every financial table carries `space_id` from day one.
- Membership links a user to a space with role `owner` or `member`; all members have full visibility inside the space. At least one owner must always remain.
- Authorization is enforced twice:
1. Application services check membership before any query.
2. Inside the request/task transaction the service runs `SET LOCAL app.space_id`, and PostgreSQL RLS policies re-check every access. A missing context fails closed.
- Workers receive IDs only, re-validate ownership, and set the same transaction-local RLS context.
- Cross-space references are forbidden by composite foreign keys `(space_id, id)` between financial tables.
- A bypass/owner role exists only for migrations and audited break-glass tooling; the API and worker roles are always RLS-restricted.
## Consequences
- Retrofitting `space_id` + RLS onto existing tables is expensive, so this must hold from the first migration (hard to reverse).
- Pooled connections must never leak context: settings are transaction-local only, and tenant-escape negative tests are mandatory.
- Break-glass requires a separate role, a mandatory reason, short-lived grants, and append-only audit.
## Alternatives rejected
- Application-level filtering only — one missed filter leaks tenant data.
- Database-per-tenant — operational cost unjustified at beta scale (≤ 100 users).
- Per-user row visibility — contradicts the space ownership model.
@@ -0,0 +1,29 @@
# ADR 0002: Journal of operations/postings and budget reserves
- **Status:** Accepted
- **Date:** 2026-09-11
- **Scope:** ledger model, balance derivation, budget plan/funding separation
## Context
The product needs trustworthy account balances including credit-card debt, zero-based monthly budgeting where the plan is separate from actual funded money, carry-over savings envelopes, and permanent per-card credit reserves. History must remain auditable and deterministic: closed budget facts must never silently change.
## Decision
- The ledger is a journal of `operations` (`expense | income | transfer | refund`; `confirmed | voided`) and `postings` (one posting for external income/expense/refund; two equal and opposite postings for an internal transfer). Amounts are integer kopecks; no floats.
- Balances are derived, never mutated directly: opening position plus confirmed, non-voided postings. Credit debt follows one documented sign convention.
- This is **not** event sourcing and **not** full double-entry against the external world: the journal is just enough structure to make balances reconstructable and voiding explicit.
- Budgeting splits plan from funding: `plan_assignments` express intent, `funding_movements` move actually received money from the general pool. Monthly categories fold their remainder into the pool at close; savings envelopes carry balances forward; each credit card has a permanent reserve.
- Period closure writes an immutable snapshot. Changing or voiding an operation in a closed month requires an explicit cascade reopen of all subsequent periods; late refunds after closure credit the current pool and never rewrite the closed fact.
## Consequences
- All balance-affecting mutations must go through journal/budget services; direct updates are excluded by convention and tests.
- Analytics for closed periods read from closure snapshots, not live aggregates.
- Plan/funding separation and closure snapshots shape the write model permanently (hard to reverse once history exists).
## Alternatives rejected
- Event sourcing — complexity without a matching need at MVP scale.
- Mutable stored balances without a journal — loses auditability and voiding semantics.
- Folding envelopes/reserves into the pool — destroys carried-forward user intent.
@@ -0,0 +1,30 @@
# ADR 0003: Versioned statement imports
- **Status:** Accepted
- **Date:** 2026-09-11
- **Scope:** statement import pipeline, parse provenance, deduplication, apply/revert semantics
## Context
Five bank statement contracts must be parsed (Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF). Bank layouts drift over time. Money facts must never change until the user has reviewed the parse; losing a real operation is worse than holding a duplicate for review. The HTTP process must not parse files.
## Decision
- An uploaded statement document is immutable: magic/MIME/size/hash checks, then storage encrypted per ADR 0004. Full extracted text is never persisted — only allowlisted statement fields plus page/row coordinates.
- Every parse produces a versioned **parse run** (adapter + parser version) yielding immutable **source records** with document origin. A reparse creates a new run and diff; previous runs are never mutated.
- State machine: `uploaded → queued → processing → review_required/ready → confirmed | failed`. Confirm applies the whole run atomically in a single DB transaction, creating only new operations/postings; existing duplicates only gain additional source links. Revert deletes exactly what that import created, with cascade reopen of affected periods.
- Deduplication is conservative: auto-link only a stable bank identifier or a single unambiguous match; ambiguous candidates stay pending until the user decides.
- Bank layout drift produces a **new parser version** with an explicit review diff — historical data is never silently reinterpreted. An adapter is beta-eligible only after the private fixture gate (see `docs/parsers/private-fixture-gate.md`).
- Parsing runs in the Celery worker, isolated and resource-limited (memory, time, pages, rows, cells).
## Consequences
- No row influences the ledger before explicit confirm; this invariant is enforced by tests (import confirm/revert idempotence).
- Parse runs and source records grow storage; acceptable at beta scale, trimmed only with the source file deletion workflow.
- Adapter code must explicitly detect its layout; a mismatch fails with stable diagnostics instead of guessing.
## Alternatives rejected
- In-request synchronous parsing — blocks HTTP workers on untrusted files.
- Mutable "latest parse" overwrites — destroys provenance and diffability.
- Aggressive auto-dedup — risks dropping real operations.
@@ -0,0 +1,29 @@
# ADR 0004: Provider-neutral images and encrypted data boundary
- **Status:** Accepted
- **Date:** 2026-09-11
- **Scope:** deployment neutrality, data residency, object encryption, secret handling
## Context
Bank statement files are sensitive personal/financial data. Legal requirements (to be confirmed by qualified counsel before real invites) demand that hosting, database, object storage and backups reside in the Russian Federation. The hosting provider may change; no cloud-proprietary service may become load-bearing. External AI calls are out of MVP scope.
## Decision
- Backend and frontend ship as provider-neutral OCI images (`infra/docker/`), depending only on commodity services: PostgreSQL, Redis, S3-compatible object storage, SMTP, and a secret manager — all locatable in the RF. Nothing in the images references a specific cloud API.
- Statement files are envelope-encrypted **before** storage: a random AES-256-GCM data-encryption key (DEK) per file, with the encrypted DEK and nonce stored in PostgreSQL and the key-encryption key (KEK) held only in the secret manager. Object keys are random and carry no PII. KEK rotation rewraps DEKs.
- Deletion is crypto-shredded: space purge removes rows and objects and destroys DEKs, so restored backups cannot resurrect file contents (see `docs/operations/deletion-ledger.md`).
- Secrets live only in the environment/secret manager. Nothing secret is committed to Git or compiled into `VITE_*` frontend variables; the frontend requires no secrets at all (same-origin `/api/v1`).
- API/worker make no outbound AI or third-party calls in MVP; the worker additionally has no egress in production.
- Observability is provider-neutral (health/readiness, queue depth, parser duration/failure per adapter version) and never carries financial payloads.
## Consequences
- The platform must supply managed PostgreSQL/Redis/S3/SMTP and a secret manager in the RF; KEK loss means permanent loss of stored statement files, so KEK backup is mandatory.
- Envelope encryption is **not** zero-knowledge: a compromised running API/worker plus KEK can decrypt files, so break-glass isolation and audit remain necessary.
- Provider neutrality is a hard architectural rule: reintroducing a cloud-specific dependency later would force a migration (treat as hard to reverse).
## Alternatives rejected
- Single-provider managed SDKs — vendor lock-in conflicts with RF-hosting flexibility.
- Server-side (SSE) bucket encryption only — insufficient given the sensitivity of raw bank statements and the crypto-shred deletion requirement.
+38
View File
@@ -0,0 +1,38 @@
# Backup and Restore
> Status: **design/runbook**. The stack is not deployed yet; no restore has been
> performed. Do not treat these targets as verified until a drill passes.
## Scope and targets (proposed)
| Item | Method | Retention | RPO target |
| --- | --- | --- | --- |
| PostgreSQL | Provider-native PITR if available; else nightly `pg_dump -Fc`, encrypted at rest | 30 days | ≤ 24 h |
| Object storage (statement files) | Provider versioning/replication of the bucket | 30 days | ≤ 24 h |
| KEK / secrets | Secret-manager backup per provider policy | indefinite | ~0 (loss = data loss) |
| Redis | none — cache/queue only, rebuilt on restore | — | acceptable loss |
| Container images | registry retained per release tag | last 10 releases | — |
Statement files are envelope-encrypted (ADR 0004): restoring PostgreSQL without
the matching DEK rows **and** the KEK makes files permanently undecryptable. KEK
backup is therefore mandatory and must be tested like a restore.
## Restore procedure (draft)
1. Provision replacement PostgreSQL/Redis/S3 endpoints in the RF region.
2. Restore the latest PostgreSQL backup into the new instance; run `alembic upgrade head` to confirm schema state matches the deployed image.
3. Restore the object bucket from versioning/replica.
4. Restore secrets: KEK, `SESSION_SECRET`, DB/S3/SMTP credentials.
5. Deploy the last known-good image tags (API, worker, frontend proxy) with the restored endpoints configured.
6. **Re-apply the durable deletion ledger** for entries executed after the backup timestamp (see `deletion-ledger.md`): re-delete purged rows/objects and re-shred DEKs.
7. Smoke test: `/healthz` on API, queue consumer alive, one synthetic import end-to-end, login with an invited test user.
8. Reopen traffic at the edge.
## Restore drill
- Cadence: before beta launch, then quarterly.
- Record in this file: date, backup age, result, defects found.
- Drills performed: **none yet**.
A disaster test is a launch gate (plan §10): restore a backup, reapply the
completed deletion ledger, and prove purged spaces/keys remain inaccessible.
+40
View File
@@ -0,0 +1,40 @@
# Beta Release Runbook (closed beta)
> Status: **template**. No release has been executed. Every unchecked item is
> still open; do not check items from this document without doing them.
## Preconditions (launch gate)
- [ ] One-shot Alembic migration applied from an empty database on staging.
- [ ] Provider-neutral images built and tagged (API/worker: `infra/docker/Dockerfile.backend`; frontend: `infra/docker/Dockerfile.frontend`) and pushed to the registry.
- [ ] Same-origin reverse proxy live at the edge with TLS + HSTS; `/api/v1` verified end-to-end (login round-trip).
- [ ] `/healthz` (API) and readiness probes wired to the platform; queue-depth and parser-failure metrics visible.
- [ ] Backups configured and a **restore drill passed** (`backup-and-restore.md`).
- [ ] KEK and secrets present in the secret manager; nothing secret in `VITE_*` or Git (ADR 0004).
- [ ] Legal/privacy gate for RF data localization **signed by qualified counsel** — hard gate before any real invite.
- [ ] Deletion workflow (30-day window, purge, ledger) reviewed against `deletion-ledger.md`.
- [ ] Bank adapters enabled only per `docs/parsers/private-fixture-gate.md`; unsupported formats are not advertised.
- [ ] Invites limited to an explicit allowlist; public registration stays off.
## Launch sequence
1. Deploy images; run migrations one-shot (`alembic upgrade head`), never on container start.
2. Smoke test on production data (synthetic only): invite → register → create space/account → plan month → manual operation → import synthetic statement → review/confirm → close month → reports.
3. Verify Mailpit-equivalent SMTP path against the production SMTP relay (invite email delivered).
4. Enable email invites for the allowlist; monitor error rates, queue depth, parser failures for the first sessions.
## Rollback
- Redeploy previous image tags; migrations are **forward-fix preferred**.
- Downgrade only where the migration is marked reversible in Alembic; otherwise restore from backup and re-apply the deletion ledger (see `backup-and-restore.md`).
- Never roll forward a parser version without a review diff (ADR 0003).
## Incidents
- Financial-data suspected exposure: revoke sessions, rotate `SESSION_SECRET`, preserve audit events, follow the (to-be-written) privacy incident procedure.
- Break-glass access: separate role, mandatory reason, short-lived grant, append-only audit — never through ordinary admin tooling.
## Post-launch
- Record adapter gate statuses and any layout-drift events.
- Keep the restore drill quarterly; update `backup-and-restore.md` drill log.
+36
View File
@@ -0,0 +1,36 @@
# Deletion Ledger
> Status: **contract definition**. The hardening workstream (plan §9) implements
> this; nothing here is verified in code yet.
## Purpose
Space deletion is final and must survive disaster restores. A backup restored
after a purge would otherwise resurrect rows, objects and metadata of deleted
spaces. The deletion ledger is an append-only record of completed deletions that
any restore **must** replay before serving traffic.
## Lifecycle
1. **Request** — user requests space deletion. The space is locked immediately (no logins, writes or imports); a 30-day cancellation window starts.
2. **Cancel** — within the window the owner may cancel; the lock is lifted, nothing is purged.
3. **Purge** (after the window, via Celery worker):
- hard-delete all space rows (financial tables, categories, payees, imports, memberships, users where applicable);
- delete all S3 objects of the space and destroy the per-file DEK rows (crypto-shred, ADR 0004);
- append one ledger entry **only after** all phases succeed.
4. **Ledger entry** — append-only, minimal fields: `space_id`, `requested_at`, `purged_at`, `executed_by (job id)`, `phases_completed`, `integrity_hash` (chain hash over prior entry). No financial payloads.
## Replay after restore
On any disaster restore (see `backup-and-restore.md` step 6):
1. Load ledger entries with `purged_at` later than the restored backup timestamp.
2. For each: re-delete the space's rows (restored backups may contain them again), re-delete its objects, re-destroy DEK rows.
3. Verify: no row with `space_id` in any financial table; bucket prefix absent; decryption of the space's files impossible.
4. Record the replay in the drill log.
## Implementation notes
- The ledger table itself is never purged by space deletion and is included in backups.
- A purge job must be idempotent and resumable: each phase is committed separately and the ledger entry is written last, in the same transaction as the final phase marker.
- Ledger integrity is checked on startup after restore (chain hash mismatch = manual escalation).
+45
View File
@@ -0,0 +1,45 @@
# Parser Private Fixture Gate
Real bank statements contain personal financial data and are **never committed**
to Git (`.gitignore` at the repo root covers this; keep fixture directories
outside the repository). CI runs synthetic fixtures only.
## Contract
- Synthetic parser inputs live with the backend tests and run in CI.
- Real statement files stay in a local `PRIVATE_FIXTURES_DIR`, never in Git, never in shared storage without encryption.
- The private suite runs explicitly, on the developer's machine:
```sh
PRIVATE_FIXTURES_DIR=/secure/path/fixtures uv run pytest -m private_fixtures
```
(Exact marker/task names are finalized by the backend workstream; the entry point contract is `PRIVATE_FIXTURES_DIR` + a dedicated pytest marker.)
## Beta gate per adapter/contract
An adapter may be declared beta-supported only when, against **private** fixtures:
- [ ] at least two complete real periods of its contract parse successfully;
- [ ] malformed file fails with stable diagnostics (no crash, no partial state);
- [ ] oversized file is rejected by quotas before parsing;
- [ ] duplicate statements produce identical parse results (hash-idempotent enqueue/apply);
- [ ] overlapping periods produce expected duplicate candidates for review, not silent merges;
- [ ] wrong-bank/encrypted/scanned (no text layer) files are detected and refused.
Record the gate result per adapter version below. An adapter without a passed
gate must not be enabled or advertised, even if synthetic tests pass (plan
§"Основные риски и gates").
## Gate status
| Adapter / parser version | Private periods passed | Gate |
| --- | --- | --- |
| Alfa debit XLSX | — | **not passed** |
| Alfa credit XLSX | — | **not passed** |
| Ozon debit PDF | — | **not passed** |
| Ozon credit PDF | — | **not passed** |
| Rocketbank debit PDF | — | **not passed** |
No adapter is currently gated. Bank layout drift resets the gate: it must create
a new parser version and a review diff, never reinterpret historical data.
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
coverage
*.local
openapi.local.json
.eslintcache
+70
View File
@@ -0,0 +1,70 @@
# Finance frontend
React + TypeScript SPA for the finance tracker: RU/EN, WCAG-aware, data-dense
desktop tables that reflow into cards on mobile.
## Stack
- Vite + React 19 + TypeScript (strict)
- React Router (library mode)
- TanStack Query v5 (space-scoped query keys, v5 `refetchInterval` callback polling)
- React Hook Form + Zod
- i18next (RU default, EN fallback)
- Vitest + Testing Library (jsdom)
- ESLint 9 (typescript-eslint, react-hooks, jsx-a11y)
## Commands
```bash
npm install # install
npm run dev # dev server on :5173, proxies /api -> http://localhost:8000
npm run build # tsc --noEmit && vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm test # vitest run
npm test:watch # vitest
npm run api:generate
```
`npm run api:generate` looks for an OpenAPI schema at `../backend/openapi.json`
(or fetches `/api/v1/openapi.json` from a running backend) and emits
`src/api/generated/schema.d.ts` via `openapi-typescript`. Until the backend
schema exists the script exits with an explanation and the hand-written client
remains the source of truth.
## API client
`src/api/client.ts` is a small typed wrapper over `fetch` bound to same-origin
`/api/v1` with `credentials: 'same-origin'`, a CSRF header read from the
`csrf_token` cookie on mutating requests, and a single `ApiError` type carrying
`status` + machine-readable `code` (translated via the `errors.*` i18n keys).
`src/api/endpoints.ts` declares one function per endpoint with payload/return
types from `src/api/types.ts`, so each group can be swapped one-for-one by
OpenAPI-generated functions without touching UI code.
Assumed endpoints (matching the approved plan): `/auth/login`, `/auth/logout`,
`/auth/me`, `/auth/invitations/accept`, `/accounts`, `/operations`,
`/budget/summary`, `/budget/categories` (+ close/reopen/copy-previous and
per-category plan/fund actions), `/imports`, `/imports/{id}` (+ confirm /
reparse / revert / row decisions), `/analytics/{category-spend|plan-fact|cash-flow|balances}`,
`/exports`, `/spaces/current/deletion`. Amounts are integer kopecks everywhere.
## Query keys
All space-data keys are rooted at `['space', ...]` (`src/api/queryKeys.ts`);
mutations invalidate by prefix (e.g. an applied import invalidates the whole
space root). Import list/detail queries poll with the v5 `refetchInterval`
callback and stop automatically in terminal states (`ready`,
`review_required`, `confirmed`, `failed`).
## Accessibility & responsiveness
- Every chart figure ships a real data table (`<details>` toggle) with
captions and scoped headers; decorative graphics are `aria-hidden`.
- Dense `<table>` markup reflows into cards under 880px via the
`data-label` pattern — no horizontal scrolling for data.
- Skip link, focus-visible rings, `aria-live` toasts, `role="alert"` errors,
`prefers-reduced-motion` support, AA-contrast palette.
No secrets are read from `VITE_*` — the app only ever talks to same-origin
`/api/v1`.
+36
View File
@@ -0,0 +1,36 @@
import js from '@eslint/js'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'coverage', 'node_modules', 'src/api/generated'] },
js.configs.recommended,
...tseslint.configs.recommended,
jsxA11y.flatConfigs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
languageOptions: {
globals: {
...globals.browser,
},
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
{
files: ['**/*.{js,mjs}'],
languageOptions: {
globals: {
...globals.node,
},
},
},
)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a1120" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Гроссбух — финансовый трекер</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{
"name": "finance-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"api:generate": "node scripts/generate-api.mjs"
},
"dependencies": {
"@hookform/resolvers": "^5.1.0",
"@tanstack/react-query": "^5.80.0",
"i18next": "^25.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.5.0",
"react-router-dom": "^7.6.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"eslint": "^9.28.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"openapi-typescript": "^7.8.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.33.0",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}
+4315
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0a1120"/>
<rect x="0.5" y="0.5" width="31" height="31" rx="6.5" fill="none" stroke="#24354f"/>
<path d="M8 22h16" stroke="#34d399" stroke-width="2.5" stroke-linecap="round"/>
<path d="M8 16h10" stroke="#fbbf24" stroke-width="2.5" stroke-linecap="round"/>
<path d="M8 10h16" stroke="#9db0c9" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { access, mkdir, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
const OUT_DIR = path.resolve('src/api/generated')
const LOCAL_CANDIDATES = [
path.resolve('../backend/openapi.json'),
path.resolve('openapi.local.json'),
]
const REMOTE_CANDIDATES = [
'http://localhost:8000/api/v1/openapi.json',
'http://localhost:8080/api/v1/openapi.json',
]
async function firstReadable(paths) {
for (const candidate of paths) {
try {
await access(candidate)
return candidate
} catch {
continue
}
}
return null
}
async function main() {
let specPath = await firstReadable(LOCAL_CANDIDATES)
if (!specPath) {
for (const url of REMOTE_CANDIDATES) {
try {
const response = await fetch(url)
if (!response.ok) continue
const spec = await response.json()
const localCopy = path.resolve('openapi.local.json')
await writeFile(localCopy, JSON.stringify(spec, null, 2))
specPath = localCopy
console.log(`api:generate: fetched OpenAPI schema from ${url}`)
break
} catch {
continue
}
}
}
if (!specPath) {
console.error(
'api:generate: no OpenAPI schema found.\n' +
'Start the backend (docker compose up api) or place the schema at ../backend/openapi.json,\n' +
'then re-run "npm run api:generate". The hand-written client in src/api stays the source of truth until then.',
)
process.exit(1)
}
await mkdir(OUT_DIR, { recursive: true })
const result = spawnSync(
'npx',
['openapi-typescript', specPath, '--output', path.join(OUT_DIR, 'schema.d.ts')],
{ stdio: 'inherit' },
)
process.exit(result.status ?? 1)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { AppRoutes } from './app/router'
import { ToastProvider } from '@/components/ui/Toaster'
import { isApiError } from '@/lib/apiErrors'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) =>
(!isApiError(error) || error.status === 0) && failureCount < 2,
refetchOnWindowFocus: false,
staleTime: 15_000,
},
mutations: { retry: false },
},
})
export default function App() {
const { t, i18n } = useTranslation()
useEffect(() => {
document.documentElement.lang = i18n.language
document.title = t('app.title')
}, [i18n.language, t])
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ToastProvider>
</QueryClientProvider>
)
}
+118
View File
@@ -0,0 +1,118 @@
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly detail: string | null
constructor(status: number, code: string, detail: string | null) {
super(detail ?? code)
this.name = 'ApiError'
this.status = status
this.code = code
this.detail = detail
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}
type QueryValue = string | number | boolean | undefined | null
export interface RequestOptions {
query?: Record<string, QueryValue>
json?: unknown
formData?: FormData
signal?: AbortSignal
}
const API_BASE = '/api/v1'
const CSRF_COOKIE = 'csrf_token'
function buildUrl(path: string, query: Record<string, QueryValue> | undefined): string {
const url = new URL(`${API_BASE}${path}`, window.location.origin)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
}
return url.toString()
}
function readCsrfToken(): string | undefined {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE}=([^;]*)`))
return match?.[1] ? decodeURIComponent(match[1]) : undefined
}
interface ErrorEnvelope {
code?: unknown
message?: unknown
detail?: unknown
error?: { code?: unknown; message?: unknown; detail?: unknown }
}
function parseErrorEnvelope(body: unknown): { code: string; detail: string | null } {
const envelope = (body ?? {}) as ErrorEnvelope
const inner = envelope.error ?? envelope
const code = typeof inner.code === 'string' ? inner.code : ''
const detail =
typeof inner.detail === 'string'
? inner.detail
: typeof inner.message === 'string'
? inner.message
: null
return { code: code || 'unknown', detail }
}
async function toApiError(response: Response): Promise<ApiError> {
let body: unknown = null
try {
body = await response.json()
} catch {
body = null
}
const { code, detail } = parseErrorEnvelope(body)
return new ApiError(response.status, code || `http_${response.status}`, detail)
}
async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
let body: BodyInit | undefined
if (options.formData) {
body = options.formData
} else if (options.json !== undefined) {
headers.set('Content-Type', 'application/json')
body = JSON.stringify(options.json)
}
if (method !== 'GET') {
const csrfToken = readCsrfToken()
if (csrfToken) headers.set('X-CSRF-Token', csrfToken)
}
let response: Response
try {
response = await fetch(buildUrl(path, options.query), {
method,
headers,
body,
credentials: 'same-origin',
signal: options.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new ApiError(0, 'network_error', null)
}
if (!response.ok) throw await toApiError(response)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
export const apiClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>('GET', path, options),
post: <T>(path: string, options?: RequestOptions) => request<T>('POST', path, options),
patch: <T>(path: string, options?: RequestOptions) => request<T>('PATCH', path, options),
delete: <T>(path: string, options?: RequestOptions) => request<T>('DELETE', path, options),
}
+111
View File
@@ -0,0 +1,111 @@
import { apiClient } from './client'
import type {
AcceptInvitePayload,
Account,
BalancesReport,
BudgetCategory,
BudgetSummary,
CashFlowMonth,
CategorySpendItem,
ConfirmImportPayload,
CreateAccountPayload,
CreateOperationPayload,
ExportInfo,
ImportDetail,
ImportSummary,
LoginPayload,
Operation,
OperationFilters,
Page,
PlanFactReport,
PasswordResetPayload,
SaveRowDecisionPayload,
SpaceDeletionStatus,
User,
} from './types'
export const api = {
auth: {
me: () => apiClient.get<User>('/auth/me'),
login: (payload: LoginPayload) => apiClient.post<User>('/auth/login', { json: payload }),
logout: () => apiClient.post<void>('/auth/logout'),
acceptInvite: (payload: AcceptInvitePayload) =>
apiClient.post<User>('/auth/invitations/accept', { json: payload }),
requestPasswordReset: (email: string) =>
apiClient.post<{ accepted: boolean }>('/auth/password-reset', { json: { email } }),
confirmPasswordReset: (payload: PasswordResetPayload) =>
apiClient.post<User>('/auth/password-reset/confirm', { json: payload }),
},
accounts: {
list: () => apiClient.get<Account[]>('/accounts'),
create: (payload: CreateAccountPayload) =>
apiClient.post<Account>('/accounts', { json: payload }),
archive: (id: string) =>
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
},
operations: {
list: (filters: OperationFilters) =>
apiClient.get<Page<Operation>>('/operations', { query: { ...filters } }),
create: (payload: CreateOperationPayload) =>
apiClient.post<Operation>('/operations', { json: payload }),
void: (id: string, reason: string) =>
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
categorize: (id: string, categoryId: string) =>
apiClient.patch<Operation>(`/operations/${id}`, { json: { categoryId } }),
},
budget: {
summary: (month: string) =>
apiClient.get<BudgetSummary>('/budget/summary', { query: { month } }),
categories: (month: string) =>
apiClient.get<BudgetCategory[]>('/budget/categories', { query: { month } }),
updatePlan: (categoryId: string, month: string, plannedKopecks: number) =>
apiClient.patch<BudgetCategory>(`/budget/categories/${categoryId}`, {
json: { month, plannedKopecks },
}),
fund: (categoryId: string, month: string, amountKopecks: number) =>
apiClient.post<BudgetCategory>(`/budget/categories/${categoryId}/fund`, {
json: { month, amountKopecks },
}),
close: (month: string) => apiClient.post<BudgetSummary>('/budget/close', { json: { month } }),
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
copyPrevious: (month: string) =>
apiClient.post<BudgetSummary>('/budget/copy-previous', { json: { month } }),
},
imports: {
list: () => apiClient.get<ImportSummary[]>('/imports'),
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
upload: (file: File, accountId: string) => {
const formData = new FormData()
formData.append('file', file)
formData.append('accountId', accountId)
return apiClient.post<ImportSummary>('/imports', { formData })
},
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
apiClient.patch<ImportDetail>(`/imports/${id}/rows/${rowId}`, { json: payload }),
confirm: (id: string, payload: ConfirmImportPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
revert: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/revert`),
},
analytics: {
categorySpend: (from: string, to: string) =>
apiClient.get<CategorySpendItem[]>('/analytics/category-spend', { query: { from, to } }),
planFact: (month: string) =>
apiClient.get<PlanFactReport>('/analytics/plan-fact', { query: { month } }),
cashFlow: (from: string, to: string) =>
apiClient.get<CashFlowMonth[]>('/analytics/cash-flow', { query: { from, to } }),
balances: () => apiClient.get<BalancesReport>('/analytics/balances'),
},
exports: {
request: () => apiClient.post<ExportInfo>('/exports'),
get: (id: string) => apiClient.get<ExportInfo>(`/exports/${id}`),
},
spaces: {
deletionStatus: () => apiClient.get<SpaceDeletionStatus>('/spaces/current/deletion'),
requestDeletion: (confirmationPhrase: string) =>
apiClient.post<SpaceDeletionStatus>('/spaces/current/deletion', {
json: { confirmationPhrase },
}),
cancelDeletion: () => apiClient.delete<SpaceDeletionStatus>('/spaces/current/deletion'),
},
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More