Skip to content
Baho73Public

About

SplitMate — учёт совместных расходов группы (тестовое задание): FastAPI + React + PostgreSQL, импорт CSV, баланс/settlement, аналитика, ИИ-ассистент text-to-SQL

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SplitMate

CI

Учёт совместных расходов группы: кто сколько потратил, кто кому должен и каким минимальным набором переводов закрыть долги. Плюс аналитика и ИИ-ассистент, который отвечает на вопросы к данным обычным языком.

Проблема

Небольшая группа (соседи, поездка, коллеги) скидывается на общие траты. Ручной подсчёт «кто кому должен» ломается на копейках, разных валютах и импорте истории из банковской выгрузки. SplitMate держит деньги в точной арифметике, честно отклоняет грязные строки CSV с указанием причины и сводит взаиморасчёт к минимуму переводов.

Архитектура

Три слоя за единым origin: React-SPA отдаёт nginx, он же проксирует /api в FastAPI; вся бизнес-логика (деление, баланс, settlement, разбор CSV, генерация SQL) живёт на сервере отдельными чистыми модулями без БД и HTTP - они тестируются как обычные функции, а роутеры остаются тонким HTTP-слоем над ними.

flowchart TD
    subgraph client["Клиент - React 18 SPA (Vite · TypeScript · recharts)"]
        UI["Вкладки группы:<br/>Расходы · Баланс · Аналитика · Импорт"]
        ASKBAR["Строка «спросите у данных» (ИИ)"]
    end

    NGINX["nginx :8080<br/>/ → статика SPA · /api → backend"]

    subgraph backend["Backend - FastAPI (Python 3.12)"]
        subgraph http["HTTP-слой (routers/)"]
            R_GROUPS["groups · expenses"]
            R_INSIGHTS["insights<br/>баланс + settlement + аналитика"]
            R_IMPORT["imports (CSV)"]
            R_ASK["ask (text-to-SQL)"]
        end
        subgraph core["Чистая логика (без БД и HTTP)"]
            SPLIT["split.py<br/>деление без потери копеек"]
            BALANCE["balance.py<br/>баланс + жадный settlement"]
            CSV["csv_import.py<br/>построчная валидация"]
            NLQ["nlq.py<br/>промпт · SQL-гард · CTE-песочница"]
        end
        ORM["models.py - SQLAlchemy 2 ORM"]
    end

    PG[("PostgreSQL 16<br/>NUMERIC(14,2) · индекс (group_id, date)")]
    LLM["OpenRouter LLM<br/>опционально, фиче-флаг"]

    UI -->|JSON /api| NGINX
    ASKBAR -->|вопрос| NGINX
    NGINX --> http
    R_GROUPS --> ORM
    R_INSIGHTS --> BALANCE
    BALANCE --> SPLIT
    R_INSIGHTS --> ORM
    R_IMPORT --> CSV
    R_IMPORT --> ORM
    R_ASK --> NLQ
    R_ASK -.->|промпт со схемой| LLM
    LLM -.->|SQL| R_ASK
    NLQ -->|валидный SELECT| R_ASK
    R_ASK -->|READ ONLY · CTE-песочница| PG
    ORM --> PG
Loading

Компоненты

Слой Что делает Модули
React-SPA Вкладки группы, дашборд на recharts, строка ИИ-вопросов frontend/src/
nginx Один origin: / - статика SPA, /api - прокси в backend frontend/nginx.conf
HTTP-слой Валидация запроса (Pydantic v2), маршрутизация; логики нет backend/app/routers/
Чистая логика Деление, баланс, settlement, разбор CSV, SQL-гард - без БД и HTTP split.py · balance.py · csv_import.py · nlq.py
ORM / БД Схема, каскады, индексы; деньги - NUMERIC(14,2) models.py · PostgreSQL 16
ИИ-слой text-to-SQL и советник импорта; выключается фиче-флагом nlq.py · routers/ask.py · llm.py

Баланс - производная величина: нигде не хранится, пересчитывается на чтении из расходов. Это исключает класс багов рассинхрона (баланс ≠ сумма расходов) ценой пересчёта на каждый запрос; при тысячах расходов пересчёт укладывается в единицы мс.

Ключевые технические решения

Это ядро проекта - где инженерные развилки, а не просто «приложение работает». Полная история решений и отвергнутых альтернатив - в DECISIONS.md.

1. Деление без потери копеек

split.py делит сумму на N равных долей так, что сумма долей строго равна расходу при любом входе. Остаток (100.00 / 3 = 33.33 + 33.33 + 33.34) детерминированно достаётся первым участникам по порядку id.

  • Почему детерминированный остаток, а не «куда попало». Иначе sum(доли) != сумма или недетерминированный результат, который нельзя закрыть тестом. Инвариант sum(shares) == amount и разброс долей <= 0.01 закрыты property-сеткой (суммы × размеры группы).
  • Trade-off. Произвольные доли (60/40) вынесены за MVP: делим поровну между всеми участниками на момент расчёта. Следствие: добавление участника ретроактивно меняет доли по старым расходам - осознанная модель «общего котла».

2. Минимизация переводов (settlement)

balance.py::settle сводит взаиморасчёт к не более чем N−1 переводам на валюту жадным алгоритмом: крупнейший должник платит крупнейшему кредитору, пока балансы не обнулятся. Цепочка долгов A→B→C схлопывается в прямой перевод A→C - посредник с нулевым нетто в переводах не участвует.

  • Почему жадный, а не оптимальный. Минимизация числа переводов в общем случае - NP-трудная задача (сводится к разбиению на подмножества с нулевой суммой). Жадный даёт границу <= N−1 (всегда не хуже наивного «каждый гасит каждого»), работает за O(n log n) и предсказуем для пользователя. Для группы из десятков человек разница с теоретическим оптимумом пренебрежима.
  • Trade-off. Не гарантируется глобальный минимум переводов, но гарантируется верхняя граница и полное закрытие долгов. Пост-условие «применение переводов обнуляет все балансы» + точный ожидаемый набор переводов на крафт-сценариях закрыты тестами.

3. Валидация CSV без «умной» починки

csv_import.py::parse_csv разбирает выгрузку построчно и не прерывается на дефектах: валидные строки импортируются, каждая битая отклоняется с номером строки и кодом причины. Порядок проверок зафиксирован контрактом (первый провал побеждает): bad_columns → missing_field → bad_date → bad_amount/nonpositive → bad_currency → unknown_payer.

  • Почему не чинить данные. Автопочинка 03/05/2026 → ISO или 1 250,50 → 1250.50 выглядит дружелюбно, но молча меняет данные пользователя (03 мая или 05 марта?). Честный отчёт об ошибке безопаснее - решение чинить остаётся за человеком (для этого есть ИИ-советник, см. ниже).
  • Почему фиксированный порядок проверок. Строка €45.00 с пустой валютой имеет два дефекта; без порядка код отклонения недетерминирован и не тестируем. Имена сопоставляются регистронезависимо той же функцией normalize_name, что и unique-индекс участников - одна нормализация на весь проект.
  • Проверяемость. Приложенный expenses_import.csv даёт ровно 1007 валидных / 9 отклонённых с точными номерами строк - тест читает настоящий файл, а не мок.

4. Безопасность text-to-SQL (главный риск ИИ-слоя)

Ассистент отвечает на вопросы к данным, генерируя SQL через LLM и исполняя его. Риск - выполнение сгенерированного кода. Защита построена в два независимых барьера + песочница:

  • Барьер 1 - гард-предикаты (nlq.py::validate_sql). Только SELECT/WITH, ровно один statement, запрет DML/DDL, системных схем, голых pg_*-каталогов и идентификационных функций (current_user, version()), WITH RECURSIVE, generate_series, комментариев. Предикаты проверяют «код» с вырезанными строковыми литералами - иначе description = 'set menu' давал бы ложный отказ, а ';' внутри кавычек прятал бы инъекцию.
  • Барьер 2 - БД read-only (routers/ask.py::execute_sandboxed). SET TRANSACTION READ ONLY + statement_timeout 5s на PostgreSQL и PRAGMA query_only на SQLite. Работает на обоих диалектах, поэтому доказан тестом в CI: пишущий SQL в обход гарда отклоняется самой БД.
  • CTE-песочница (nlq.py::wrap_sql). Запрос видит только данные своей группы (bind :nlq_gid), card_number в песочнице и в промпте отсутствует вовсе, всегда LIMIT 200.
  • Почему два барьера. Regex-гард - эвристика, а не полноценный SQL-парсер; главная защита от записи - барьер БД, а regex ловит основные векторы и укрепляется тестом при каждой найденной дыре. Известные ограничения демо (приватность промпта, отсутствие rate-limit) перечислены явно в DECISIONS.md, а не спрятаны.

5. Деньги - только точная арифметика

Во всех слоях деньги - Decimal (Python) / NUMERIC(14,2) (PostgreSQL) / строки в JSON. Float для денег запрещён: 0.1 + 0.2 != 0.3 в двоичной плавающей точке ломает инвариант нулевой суммы балансов. Валюты не конвертируются - балансы и переводы ведутся по каждой валюте отдельно (курс с историчностью - неоправданная сложность для MVP).

Структура API

Ресурсно-ориентированный REST под /api: groups, expenses, insights (/balance, /analytics), imports, ask. Агрегаты аналитики считает SQL (GROUP BY), фронт не тянет сырые строки; список расходов - серверная пагинация + индекс (group_id, date) (p95 ~16 мс на 1007 записях).

Стек

Слой Технологии
Frontend React 18, TypeScript, Vite 6, React Router 6, recharts
Backend Python 3.12, FastAPI, SQLAlchemy 2, Pydantic v2, psycopg 3
БД PostgreSQL 16 (NUMERIC(14,2), unique/индексы, каскады)
Инфра Docker Compose, nginx (single-origin прокси)
ИИ (опц.) OpenRouter (по умолчанию anthropic/claude-haiku-4.5)
Тесты / CI pytest (SQLite in-memory), GitHub Actions

Запуск

Нужен только Docker (Compose v2). На чистой машине:

docker compose up -d --build

Приложение целиком - на http://localhost:8080 (фронт и /api на одном origin за nginx). Данные живут в volume PostgreSQL и переживают перезапуск. Если порт занят:

SPLITMATE_PORT=8081 docker compose up -d --build
# PowerShell: $env:SPLITMATE_PORT='8081'; docker compose up -d --build

Переменные окружения

Переменная Назначение По умолчанию
SPLITMATE_PORT Внешний порт публикации 8080
DATABASE_URL Строка подключения к PostgreSQL задаётся в compose
OPENROUTER_API_KEY Включает ИИ-ассистента (см. ниже) пусто → ИИ выключен
NLQ_MODEL Модель для text-to-SQL anthropic/claude-haiku-4.5

Секреты (.env) в git не коммитятся (см. .gitignore). Без ключа приложение полностью работоспособно - ассистент просто выключен (баннер).

ИИ-ассистент (сверх ТЗ, опционально)

Создать .env рядом с docker-compose.yml:

OPENROUTER_API_KEY=sk-or-...
NLQ_MODEL=anthropic/claude-haiku-4.5

Вкладка «Аналитика» → строка «спросите у данных»: вопрос обычным языком («Кто сколько потратил в марте?») → ИИ пишет SQL → сервер исполняет в песочнице (только чтение, только данные группы) → таблица/график; ответ можно закрепить как виджет. В импорте ИИ разбирает отклонённые строки и предлагает исправления с вопросом-подтверждением (вставка - только после подтверждения человеком через тот же parse_csv).

Тесты

cd backend
pip install -r requirements.txt pytest
python -m pytest -q      # 89 passed за ~2 с

CI (GitHub Actions, .github/workflows/ci.yml) на каждый push/PR ставит Python 3.12, зависимости и гоняет весь набор. Тесты идут на SQLite in-memory - внешний PostgreSQL для прогона не нужен.

Что покрыто (инварианты денег закрыты явными assert):

  • split - sum(доли) == сумма на property-сетке; детерминированный остаток; ошибки входа.
  • balance / settlement - пример из ТЗ; нулевая сумма балансов по каждой валюте; мультивалюта; точный минимизированный набор переводов на крафт-сценариях; схлопывание цепочки долгов; применение переводов обнуляет балансы; ретроактивный пересплит.
  • csv - детерминированные причины отказа (параметрический перебор); канонизация имён; эталон настоящего expenses_import.csv = 1007/9 с точными номерами строк.
  • nlq / ask - каждый класс инъекции отклоняется своим кодом; барьер записи БД (даже в обход гарда); песочница не видит чужую группу и card_number; кэш переживает дрейф схемы; советник импорта.
  • api - CRUD, баланс, settlement, импорт эталона через HTTP, аналитика (агрегаты сходятся с итогом), пагинация/фильтры, каскады, 404/409/413/422.

Пример из ТЗ

Группа Anna/Boris/Sveta, Anna платит 100.00 → баланс Anna +66.66, Boris −33.33, Sveta −33.33 (доли 33.34/33.33/33.33, лишняя копейка первому участнику; сумма строго 0). Покрыт тестами test_spec_example_balance и test_spec_example_anna_100_for_three.

Структура репозитория

backend/app/          FastAPI: split.py, balance.py, csv_import.py, nlq.py - чистая логика;
                      routers/ - HTTP; models.py - ORM; тесты в backend/tests/
frontend/src/         React SPA: страницы, вкладки группы, дашборд
docs/                 Проектные артефакты (GRACE): требования, план, граф знаний, верификация
.github/workflows/    CI: pytest на Python 3.12
docker-compose.yml    nginx (:8080) + backend + postgres (volume)
DECISIONS.md          Принятые решения, допущения, отвергнутые альтернативы и почему

Проект ведётся контракт-первым методом (GRACE): контракты модулей и план верификации написаны до кода, рисковые контракты прошли отдельный формальный разбор - история решений видна в docs/ и в истории коммитов.

About

SplitMate — учёт совместных расходов группы (тестовое задание): FastAPI + React + PostgreSQL, импорт CSV, баланс/settlement, аналитика, ИИ-ассистент text-to-SQL

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages