Личные траты записываются одной строкой боту — «кофе 350», «такси 900 работа». Веб-панель показывает, куда уходят деньги: по дням, по категориям, за неделю и за месяц. Отдельной регистрации нет: аккаунт — это Telegram.
Демо остановлено 6 сентября 2026. Проект делался на конкурс CodeCup.tech; после подведения итогов бот и панель выключены, домен
tracker.somonvpn.techбольше не отвечает. Код рабочий: как поднять у себя одной командой — в «Запуск локально» и docs/DEPLOY.md.
Ввод. Одно сообщение — одна трата. Сумма может стоять до или после описания, дробная часть пишется точкой или запятой, «1.5к» — это полторы тысячи, «20с» и «45 usd» — валюта. Дата в тексте распознаётся: «вчера такси 30», «3 сентября обед 90», «01.09 кофе 20». Несколько трат в одном сообщении разделяются точкой с запятой.
Категории. 13 групп, 1255 ключевых слов на трёх языках: русские,
таджикские (нон, гӯшт, дорухона, тӯй), английские (groceries,
rent, pharmacy) и местные реалии — маршрутка, чойхона, Барки Точик, Тсел,
Алиф. Опечатки прощаются: «маршутка», «продкты», «апетка» находятся. Если
категория угадана спорно, бот кладёт под карточку две кнопки в одно касание;
исправление запоминается и в следующий раз применяется автоматически.
Языки. Русский, таджикский, английский — и бот, и панель, и меню команд
в клиенте Telegram. При /start бот спрашивает язык и город; язык можно
сменить в любой момент через /settings. Часовой пояс определяет, что
считать «сегодня», а валюта отчётов подставляется по стране города.
Отчёты. /today, /week, /month в боте и те же цифры в панели — их
считает один и тот же код, разойтись они не могут.
Правка. Трату можно изменить или удалить и из бота (кнопки под карточкой), и из панели (раскрывающаяся строка). Изменение в панели переписывает карточку в чате. Удаление мягкое — пять секунд доступна кнопка «Вернуть».
Премиум-эмодзи. Сорок семь штук: у каждой кнопки нижней панели, у
кнопок категорий и действий, в заголовке отчёта, в стрелке сравнения,
в настройках и в мастере. Иконка кнопки ставится полем
icon_custom_emoji_id и рисуется перед текстом, поэтому подпись
остаётся чистой и обработчики кнопок не ломаются. Идентификаторы не
подобраны, а взяты у Telegram методом getStickerSet — все 997 лежат
в assets/premium-emoji.txt, и тест сверяет с ними каждый
используемый. Если Premium у владельца бота кончится, Telegram начнёт
отклонять сообщения целиком; на этот случай первый же такой отказ
гасит эмодзи, и сообщение уходит повторно обычным.
Оформление. Тёмная тема — основная, светлая переключается кнопкой в подвале панели и запоминается в браузере. Системная настройка тему не навязывает: продукт нарисован как «ночная касса» и должен так выглядеть у всех, а не только у тех, у кого телефон в тёмном режиме.
Бонусом: распознавание сумм с фотографии чека, несколько валют с пересчётом по курсу, месячные лимиты по категориям с предупреждением на 80 % и 100 %, выгрузка в CSV (открывается в русском Excel без плясок с кодировкой).
src/
lib/ общее ядро — им пользуются и бот, и панель
parser.ts разбор строки: сумма, валюта, дата, описание
categorize.ts определение категории: фразы → словарь → опечатки → правила пользователя
categories.ts 13 категорий и словарь на 1255 слов
stats.ts агрегаты: по дням, по категориям, сравнение периодов
time.ts границы периодов в часовом поясе пользователя
money.ts деньги в целых минорных единицах, форматирование
rates.ts курсы валют с офлайн-запасом
expenses.ts создание, правка, удаление, лимиты
auth.ts одноразовые ссылки входа, сессии, проверка initData
ocr.ts чтение чека и ранжирование найденных сумм
bot/ Telegram-бот на grammY (long polling)
app/ Next.js App Router: страницы и API
components/ панель
tests/ 218 тестов, запускаются без сети и без Telegram
Ядро отделено от бота и от панели намеренно: любая цифра считается ровно одним куском кода. Если бы бот и панель считали «за неделю» по-разному, никакие графики уже не помогли бы.
Данные. SQLite. Один файл, никакого отдельного сервера — судье не нужно ничего устанавливать, а на сервере нечему падать по отдельности. Все суммы хранятся целыми числами в минорных единицах: с плавающей точкой деньги считать нельзя.
Изоляция. user_id стоит внутри WHERE каждого запроса, а не проверяется
после выборки. Чужую трату нельзя ни прочитать, ни изменить, ни удалить, даже
зная её идентификатор — на такой запрос приходит 404. Это покрыто тестами.
Два пути, оба через Telegram:
- Ссылка из бота (основной). Команда
/panelприсылает кнопку. Ссылка живёт 10 минут и срабатывает один раз — переслать её бесполезно. - Telegram Mini App. Если панель открыта внутри Telegram, подпись
initDataпроверяется ботовым токеном, и сессия выдаётся без ссылки.
Токен входа хранится в базе только в виде хеша, а забирается единственным
UPDATE с условием — две открытые вкладки не создадут двух сессий.
Забирается он по нажатию кнопки, то есть POST-запросом: краулер превью
ссылок Telegram открывает адрес раньше человека, и на GET токен сгорал бы
до того, как пользователь по нему перейдёт.
Вход через oauth.telegram.org (OIDC) сознательно не делается: он требует
регистрации разрешённых адресов и client secret в BotFather — лишний барьер
при запуске, а два пути выше требование закрывают полностью.
Нужен Node.js 22.12 или новее. Больше ничего ставить не надо: база — файл, бот работает через long polling, туннель не требуется.
git clone https://github.com/aminyx/somoni-tracker.git
cd somoni-tracker
npm install
cp .env.example .envОткройте .env и заполните два обязательных поля:
TELEGRAM_BOT_TOKEN— токен от @BotFather;SESSION_SECRET— случайная строка, сгенерировать можно так:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Дальше:
npm run db:migrate
npm run devПанель — http://localhost:3000, бот отвечает в Telegram. Напишите ему
/start, потом кофе 350, потом /panel — придёт ссылка на панель.
Посмотреть панель с данными, ничего не набирая руками:
npm run seedКоманда наполнит демонстрационный аккаунт полусотней трат за полтора месяца
и напечатает готовую ссылку входа. То же самое из бота — команда /demo,
она добавляет примеры в ваш собственный аккаунт, а /demo_clear их
убирает.
Telegram отдаёт обновления только одному потребителю. Если запустить бота
локально с тем же токеном, что и на сервере, один из них замолчит. Для
разработки заведите второго бота у @BotFather и положите его токен
в TELEGRAM_BOT_TOKEN_DEV — в режиме разработки берётся он.
npm run dev:web # только панель
npm run dev:bot # только бот
npm test # 218 тестов, сеть и Telegram не нужны
npm run typecheck
npm run build| Переменная | Обязательна | Что это |
|---|---|---|
TELEGRAM_BOT_TOKEN |
да | Токен бота от @BotFather. Нужен и боту, и панели — ей для проверки подписи Mini App. |
SESSION_SECRET |
да | Случайная строка не короче 16 символов. |
TELEGRAM_BOT_USERNAME |
— | Имя бота без @, для ссылок t.me/…. |
APP_URL |
— | Публичный адрес панели; бот подставляет его в кнопку. По умолчанию http://localhost:3000. |
PORT |
— | Порт панели, по умолчанию 3000. |
DATABASE_PATH |
— | Путь до файла SQLite, по умолчанию ./data/tracker.db. Каталог создаётся сам. |
DEFAULT_TIMEZONE |
— | Часовой пояс новых пользователей, по умолчанию Asia/Dushanbe. Меняется командой /settings. |
DEFAULT_CURRENCY |
— | Валюта отчётов по умолчанию, TJS. |
EXCHANGE_RATES_URL |
— | Источник курсов. По умолчанию open.er-api.com — без ключа и регистрации. Недоступен — берётся встроенная таблица. |
ENABLE_RECEIPT_OCR |
— | true — бот читает суммы с фото чека. Требует ~300 МБ памяти. |
TELEGRAM_BOT_TOKEN_DEV |
— | Отдельный токен для локальной разработки, см. выше. |
Секретов в репозитории нет. .env в .gitignore, в .env.example лежат
только заглушки.
«Сегодня» считается в часовом поясе пользователя, а не сервера. В два часа ночи по Душанбе на сервере в UTC ещё вчерашний день, и итог за сегодня показал бы вчерашние траты. Границы суток, недели и месяца считаются через IANA-зону; переход на летнее время в других зонах учтён (сутки бывают 23 и 25 часов).
Неделя — календарная, с понедельника. В заголовке пишется не слово «неделя», а даты: «1–7 сентября». Слово допускает два толкования, даты — нет.
Сравнение с прошлым периодом обрезано по числу прошедших дней. Третьего сентября панель сравнивает с 1–3 августа, а не с целым августом, и прямо это подписывает: «за те же 3 дня августа». Иначе «−89 %» — это не экономия, а просто ещё не наступивший месяц.
На графике нарисованы все дни месяца, включая пустые. Если показывать только дни с тратами, три покупки выглядят как плотный месяц.
Столбики, а не кольцо. Русские названия категорий длинные и требуют легенды на пол-экрана; и главное — при одной трате кольцо превращается в сплошной круг и выглядит сломанным. Долю от целого берёт на себя полоска в шесть пикселей.
Удаление без вопроса «вы уверены», но с кнопкой «Вернуть». Диалог подтверждения ради траты на 250 сомони — трение ради вида.
Сообщение бота при удалении переписывается, а не удаляется. Bot API разрешает удалять сообщения только первые 48 часов, а править собственные — всегда.
Нужны: сервер с Docker, домен и HTTPS. Бот работает через long polling, входящие порты ему не нужны — наружу смотрит только панель.
cp .env.example .env # заполнить TELEGRAM_BOT_TOKEN, SESSION_SECRET, APP_URL
docker compose up -d --buildПоднимаются два контейнера из одного образа: web (панель на 127.0.0.1:3000)
и bot. База лежит в томе ./data и переживает пересборку. Подробности,
включая минимальную правку конфигурации Caddy и порядок отката —
в docs/DEPLOY.md.
Проверка живости: /api/health отвечает {"ok":true,"db":"ok"} — по нему же ходил сторож, пока стенд работал.
npm test218 тестов, сеть и Telegram не нужны. Покрыто то, что ломается тихо: границы периодов в часовых поясах и при переходе на летнее время, разбор строки траты (36 случаев), непротиворечивость словаря категорий, направление курса валют, одноразовость ссылки входа, пороги лимитов и — отдельно — изоляция данных: чужую трату нельзя прочитать, изменить или удалить.
Часть проверок читает исходники, потому что эти поломки сборка не ловит: кнопка без обработчика уйдёт в разбор траты; вернувшийся в CSS медиазапрос светлой темы отменит тёмную; потерянный перевод оставит категорию русской. Отдельно проверяется обещание продукта — каждый пример, который бот сам предлагает написать, должен определяться в категорию на своём языке. И отдельно — премиум-эмодзи: выдуманный идентификатор не даёт ошибки, он рисуется пустым квадратом уже на экране у человека, поэтому каждый сверяется с выгрузкой набора вместе с запасной эмодзи.
Пришлите боту фото чека — он прочитает его и предложит найденные суммы кнопками. Выбор остаётся за человеком: трекер, который уверенно назвал неправильную сумму, хуже трекера без распознавания вовсе.
Работает офлайн, ключей и внешних сервисов не нужно: PaddleOCR в виде моделей ONNX, около 12 МБ, скачиваются при первом запуске и кэшируются.
Модель взята кириллическая (PP-OCRv5 cyrillic), а не универсальная v6.
У v6 в словаре нет кириллицы: числа она читает верно, но «ИТОГО»
превращается в «HTOTO», а без слова-якоря итог не отличить от суммы НДС
и от сдачи. tesseract.js проверялся первым и отпал: на настоящем чеке он
прочитал итог как 199 вместо 4199 и испортил само слово «НДС».
Ранжирование сумм разбирается отдельно от распознавания и покрыто тестами на тексте, снятом с настоящего чека: сумма НДС, сдача, внесённые наличные, ИНН, номер чека, дата и строки «2 × 5,00» в кандидаты не попадают.
Выключается переменной ENABLE_RECEIPT_OCR=false. Распознавание стоит около
300 МБ оперативной памяти; при выключенном флаге onnxruntime вообще не
загружается в процесс.
Совместный бюджет на двоих не сделан: он затрагивает условие выборки в каждом запросе, а изоляция данных здесь важнее лишнего пункта.
Весь прикладной код написан для этого проекта. Ниже — стороннее, чтобы не оставалось вопросов об источниках.
Библиотеки (полный список с версиями — в package.json):
Next.js, React,
grammY, Drizzle ORM,
better-sqlite3,
Tailwind CSS, Zod,
ppu-paddle-ocr.
Модели распознавания. PaddleOCR PP-OCRv5, кириллический вариант — скачиваются при первом запуске с huggingface.co/snowfluke/ppu-paddle-ocr-models и в репозитории не хранятся. Исходный PaddleOCR — Apache 2.0.
Шрифты. Onest и
JetBrains Mono,
оба под SIL Open Font License, подключены через next/font.
Курсы валют. open.er-api.com — бесплатно
и без ключа. Запасная таблица зашита в src/lib/rates.ts.
Словарь категорий составлен для этого проекта. При его сборке сверялись публичные списки категорий Wallet by BudgetBakers, Monefy и CoinKeeper, а местные реалии (маршрутка, Барки Точик, Тсел, Алиф, названия базаров) проверялись по открытым источникам о Душанбе. Чужие списки не копировались: набор из 13 категорий и 1255 ключевых слов — свой, в том числе таджикская лексика, которой нет ни в одном из них.
Автор: Aminyx