A multi-user Telegram bot that logs expenses to YNAB (You Need A Budget) using OpenAI GPT-4o-mini for natural language parsing and Whisper for voice transcription. Targeted at Spanish-speaking users managing budgets in Colombian pesos.
- π€ Voice Recognition: Send audio messages and the bot transcribes them automatically with Whisper
- πΈ Receipt Scanning: Send a photo of a receipt and the bot extracts amount, merchant, and category automatically
- π§ AI-Powered: Uses OpenAI GPT-4o-mini to understand expenses and budget queries in natural Spanish language
- π Budget Queries: Ask about category balances, account balances, or get a budget summary in natural language
- π Date Parsing: Supports relative ("ayer", "el lunes") and absolute ("24/07", "el 5 de marzo") dates for backdating expenses
- π₯ Shared Expenses: Split expenses with other people β supports user-paid and third-party paid scenarios with automatic YNAB subtransactions
- π Adaptive Learning: Remembers your spending patterns, explains categorization decisions, and improves over time
- π³ Account Detection: Automatically identifies the bank account mentioned
- πͺ Smart Categorization: Assigns real YNAB categories based on merchant/location with semantic matching
- π₯ Multi-User: Authentication system with admin approval and guided onboarding
- π Per-User OAuth: Each user connects their own YNAB account via OAuth2
- βοΈ Edit & Undo: Edit recent transactions (amount, payee, category, account) or undo the last one entirely
- β Confirmation Mode: Optional pre-registration preview β the bot shows what it will log and waits for confirmation
- π Weekly & On-Demand Summaries: Automatic weekly spending summary every Monday + on-demand summary via
/resumen - π Timezone Support: Per-user timezone configuration for accurate date handling and weekly summaries
- π Statistics: View learning progress, top payees/categories, and accuracy improvements
ynab-bot/
βββ main.py # Entry point
βββ requirements.txt # Python dependencies
βββ pytest.ini # Test configuration
βββ railway.toml # Railway deployment config (test-gating)
βββ src/
β βββ domain/ # Models, interfaces, exceptions
β β βββ models/ # Expense, BudgetQueryResult, UserConfiguration, SplitGroup, OnboardingStep
β β βββ repositories/ # Abstract interfaces (ABC)
β β βββ services/ # AuthorizationService, payee_normalizer
β β βββ exceptions.py # Includes OAuthException, TokenExpiredException
β βββ application/services/ # Business logic orchestrators
β β βββ expense_service.py # Pipeline: parseβenhanceβcreateβlearn + query routing + shared expenses
β β βββ budget_query_service.py # Budget queries (category/account balance, summary)
β β βββ user_config_service.py # Per-user configuration
β β βββ oauth_service.py # YNAB OAuth2 lifecycle (auth, tokens, refresh)
β β βββ learning_service.py # Dashboard, forget, stats
β β βββ onboarding_service.py # Guided onboarding state derivation
β β βββ split_config_service.py # Split group/alias/shared account management
β β βββ weekly_summary_service.py # Automated weekly spending summary
β β βββ on_demand_summary_service.py # On-demand spending summary
β βββ infrastructure/
β β βββ config/app_config.py # Loads config/.env
β β βββ container.py # Dependency injection (DIContainer)
β β βββ health.py # Health check + OAuth callback HTTP server
β β βββ scheduler.py # Weekly summary job scheduler
β β βββ http_client.py # Resilient HTTP client with retries
β β βββ logging_config.py # Structured logging setup
β β βββ token_encryption.py # Fernet encryption for tokens at rest
β β βββ telegram_notifier.py # Sync Telegram API wrapper (post-OAuth notifications)
β β βββ repositories/ # SQLite, YNAB API, YNABRepositoryFactory
β βββ presentation/telegram/
β β βββ bot.py # Handler registration
β β βββ formatters.py # Message formatting (expenses, queries, shared)
β β βββ keyboards.py # Inline keyboard builders (budgets, accounts, split config)
β β βββ handlers/ # General, Config, Expense, Learning, SplitConfig, Summary, Admin
β β βββ middleware/ # @require_authentication, @require_admin
β βββ parsers/
β β βββ llm_expense_parser.py # GPT-4o-mini parser (intent classification, date parsing, shared expenses)
β βββ integrations/
β βββ speech_to_text.py # Whisper transcription
βββ config/
β βββ .env # Environment variables (private)
β βββ .env.example # Configuration template
βββ data/ # Persistent data
β βββ users.db # SQLite database (users, learning, split config)
βββ tests/ # Test suite (~1112 tests, ~92% coverage)
git clone <repository-url>
cd ynab-botpython -m venv .venv
source .venv/bin/activate # or: source .venv/bin/activate.fish
pip install -r requirements.txtcp config/.env.example config/.envEdit config/.env with your tokens:
| Variable | Description | Required |
|---|---|---|
TELEGRAM_BOT_TOKEN |
Telegram bot token (via @BotFather) | Yes |
OPENAI_API_KEY |
OpenAI API key (API Keys) | Yes |
ADMIN_IDS |
Telegram user IDs for admins (comma-separated) | Yes |
YNAB_CLIENT_ID |
YNAB OAuth app client ID (Developer Settings) | Yes |
YNAB_CLIENT_SECRET |
YNAB OAuth app client secret | Yes |
YNAB_REDIRECT_URI |
OAuth callback URL (e.g. https://your-domain.up.railway.app/oauth/callback) |
Yes |
TOKEN_ENCRYPTION_KEY |
Fernet key for encrypting tokens at rest (see below) | Yes |
DATABASE_PATH |
Path to SQLite database (users + learning data) | No (default: data/users.db) |
Generate a Fernet encryption key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"python main.py # Starts the botGeneral:
/startβ Registration and welcome message/helpβ Help and usage examples
YNAB Connection:
/connectβ Connect your YNAB account via OAuth/disconnectβ Disconnect your YNAB account and clear tokens
Configuration:
/configβ Configure YNAB budget and accounts/budgetsβ List available budgets/accountsβ List available accounts/statusβ View current configuration and YNAB connection status/zona <timezone>β Configure timezone (e.g./zona America/Bogota)/confirmacion on|offβ Enable/disable confirmation before registering expenses
Shared Expenses:
/splitwiseβ Configure Splitwise groups, person aliases, and shared account
Learning & Transactions:
/statsβ Learning statistics (top payees and categories)/aprendizajeβ View learned payee-category associations with frequency/olvidar <payee>β Delete incorrect associations for a payee/recentβ View recent transactions/editar [n] <campo> <valor>β Edit a recent transaction (amount, payee, category, account). Optional indexn(default: last)./deshacerβ Undo the last transaction (deletes from YNAB, decrements learning)
Summaries:
/resumenβ On-demand spending summary (day/week/month with category breakdown)
Administration (admins only):
/adminβ Admin panel/pendingβ View users pending approval/usersβ View all users/approve <user_id>β Approve a user/block <user_id>β Block a user
The bot understands various natural language formats for logging expenses in Spanish:
"GastΓ© $50000 en comida en Γxito"
"Me gastΓ© 25000 pesos en transporte"
"$30000 comida Carulla"
"45000 pesos gasolina con mi tarjeta Nu"
"25 lucas almuerzo McDonald's"
"80k gasolina estaciΓ³n Terpel"
"Almuerzo 25000 en Home Burguer"
"Ayer gastΓ© 30k en restaurante"
"El lunes paguΓ© 15000 en farmacia"
Amount formats: 40000, 40 mil, 40 lucas, 40k, $40000, decimals with comma (40000,50).
Date formats: ayer, anteayer, el lunes, la semana pasada, 24/07, el 5 de marzo. If no date is mentioned, today is assumed.
The bot supports shared expenses with automatic YNAB subtransactions:
"Almuerzo compartido con Juan 30k" β 50/50 split, user paid
"Cena con MarΓa 60k, ella pagΓ³" β 50/50 split, third-party paid
"Juan pagΓ³ 100k de mercado por mΓ" β 100% debt, third-party paid
User-paid split: Creates subtransactions splitting the amount between the real category and the Splitwise tracking category.
Third-party paid split: Creates a zero-sum transaction β the real category outflow is balanced by an inflow from the Splitwise tracking category, so your budget reflects the debt without affecting your account balance.
Configure split groups, person aliases, and tracking accounts via /splitwise.
Ask about your budget in natural language:
"ΒΏCuΓ‘nto me queda en comida?" β Category balance (matches "π Groceries" semantically)
"ΒΏCuΓ‘nto debo en mi Nu Card?" β Account balance (confirmed/pending)
"ΒΏCΓ³mo va mi presupuesto?" β Budget summary with top spending categories
"ΒΏCuΓ‘nto he gastado en restaurantes?" β Category balance (matches "π½οΈ Dining Out" or similar)
"ΒΏCuΓ‘l es el saldo de mi cuenta?" β Account balance
The bot uses AI-powered semantic matching to map natural language terms to your actual YNAB categories β you don't need to remember exact category names. It distinguishes between expenses and queries automatically.
Send a photo of a receipt or ticket and the bot will:
- Analyze the image using OpenAI GPT-4o-mini vision
- Extract the total amount, merchant, and individual items (for memo)
- Log it to YNAB automatically
You can add a caption to the photo for additional context (e.g., "lunch with friends").
Send a voice message in Spanish and the bot will:
- Transcribe the audio using OpenAI Whisper
- Parse the expense information
- Log it to YNAB automatically
The bot detects bank accounts mentioned in messages:
- "con mi tarjeta Nu" β Nu Card account
- "con Rappi Card" β Rappi Card account
- "efectivo" β Cash account
- Use
/editar categoria <nueva categorΓa>to correct the last transaction's category (the bot learns from the correction) - Use
/editar monto <nuevo monto>to fix the amount - Use
/editar 2 categoria Restaurantesto edit the second-to-last transaction - Use
/deshacerto delete the last transaction entirely (also reverts learning)
The bot implements a multi-user system with admin approval and per-user OAuth:
- A new user sends
/startβ status set to PENDING - All other commands are blocked until approved
- An admin reviews the request with
/pendingand approves or blocks - The user receives a notification and can start using the bot
- User connects their own YNAB account via
/connect(OAuth2 Authorization Code flow) - After OAuth, configures their budget (
/budgets) and account (/accounts)
User statuses: PENDING β AUTHORIZED | BLOCKED
Each user connects their own YNAB account. No shared tokens.
- User sends
/connectβ bot generates an authorization URL with HMAC-SHA256 signed state - User clicks the link β authorizes the app on YNAB's site
- YNAB redirects to the bot's callback endpoint (
/oauth/callback) with an authorization code - Bot exchanges the code for access + refresh tokens, encrypts them with Fernet, and stores in SQLite
- Tokens are automatically refreshed when expired
Security:
- OAuth state parameter signed with HMAC-SHA256 (using
YNAB_CLIENT_SECRET) to prevent CSRF - Tokens encrypted at rest with Fernet symmetric encryption (
TOKEN_ENCRYPTION_KEY) - Tokens are never logged
# Full suite (~1112 tests, ~92% coverage)
pytest
# Single test file
pytest tests/test_domain_models.py
# Single test class or method
pytest tests/test_domain_models.py::TestExpense::test_is_valid_basic
# Filter by name
pytest -k "test_predict_category"Layered architecture with dependency injection:
main.py β DIContainer (infrastructure/container.py) β YNABTelegramBot (presentation/telegram/bot.py)
Message processing flow:
User (text)
β ExpenseService.process_message()
β LLMExpenseParser.parse_message() β classifies intent ("expense" | "query" | "shared_expense")
β LLM semantically maps user terms to exact YNAB category/account names
β if expense: prepare pipeline (parseβenhance) β confirm or auto-commit β createβlearn
β if shared_expense: split logic (subtransactions or zero-sum) β createβlearn
β if query: BudgetQueryService (4-step fuzzy fallback) β category/account/summary data
β Formatted Telegram response
User (voice)
β Whisper transcription
β ExpenseService.process_expense_message() β expense pipeline
User (photo)
β GPT-4o-mini vision (receipt extraction)
β ExpenseService.process_receipt_image() β expense pipeline
The bot is configured for deployment on Railway with:
- Test-gating: Tests run before every deploy; failures cancel the deployment (
railway.toml) - Health check: Built-in HTTP server at
/for Railway health probes - OAuth callback: Same HTTP server handles
/oauth/callbackfor the YNAB OAuth flow
- Go to YNAB Developer Settings
- Create a new OAuth Application
- Set the Redirect URI to
https://<your-railway-domain>/oauth/callback - Copy the Client ID and Client Secret to your environment variables