Учёт совместных расходов группы: кто сколько потратил, кто кому должен и каким минимальным набором переводов закрыть долги. Плюс аналитика и ИИ-ассистент, который отвечает на вопросы к данным обычным языком.
Небольшая группа (соседи, поездка, коллеги) скидывается на общие траты. Ручной подсчёт «кто кому должен» ломается на копейках, разных валютах и импорте истории из банковской выгрузки. 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
Компоненты
| Слой | Что делает | Модули |
|---|---|---|
| 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.
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: делим поровну между всеми участниками на момент расчёта. Следствие: добавление участника ретроактивно меняет доли по старым расходам - осознанная модель «общего котла».
balance.py::settle сводит взаиморасчёт к не более чем N−1 переводам на валюту
жадным алгоритмом: крупнейший должник платит крупнейшему кредитору, пока балансы не
обнулятся. Цепочка долгов A→B→C схлопывается в прямой перевод A→C - посредник с
нулевым нетто в переводах не участвует.
- Почему жадный, а не оптимальный. Минимизация числа переводов в общем случае -
NP-трудная задача (сводится к разбиению на подмножества с нулевой суммой). Жадный
даёт границу
<= N−1(всегда не хуже наивного «каждый гасит каждого»), работает заO(n log n)и предсказуем для пользователя. Для группы из десятков человек разница с теоретическим оптимумом пренебрежима. - Trade-off. Не гарантируется глобальный минимум переводов, но гарантируется верхняя граница и полное закрытие долгов. Пост-условие «применение переводов обнуляет все балансы» + точный ожидаемый набор переводов на крафт-сценариях закрыты тестами.
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 отклонённых с точными номерами строк - тест читает настоящий файл, а не мок.
Ассистент отвечает на вопросы к данным, генерируя 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, а не спрятаны.
Во всех слоях деньги - Decimal (Python) / NUMERIC(14,2) (PostgreSQL) / строки в
JSON. Float для денег запрещён: 0.1 + 0.2 != 0.3 в двоичной плавающей точке ломает
инвариант нулевой суммы балансов. Валюты не конвертируются - балансы и переводы ведутся
по каждой валюте отдельно (курс с историчностью - неоправданная сложность для MVP).
Ресурсно-ориентированный 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/ и в истории коммитов.