Skip to content

About

Expense tracker in Telegram: one-line input in RU/TJ/EN with typo-tolerant categories + a web analytics dashboard (Next.js, grammY, Drizzle, SQLite)

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Трекер расходов в Telegram

Личные траты записываются одной строкой боту — «кофе 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:

  1. Ссылка из бота (основной). Команда /panel присылает кнопку. Ссылка живёт 10 минут и срабатывает один раз — переслать её бесполезно.
  2. 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 test

218 тестов, сеть и 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

About

Expense tracker in Telegram: one-line input in RU/TJ/EN with typo-tolerant categories + a web analytics dashboard (Next.js, grammY, Drizzle, SQLite)

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages