├── .dockerignore ├── .env.example ├── .github ├── FUNDING.yml └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── README.md ├── add_column.py ├── app.py ├── demo_reset.py ├── docker-compose.dev.yml ├── docker-compose.yml ├── fix_currency.py ├── fmp_cache.py ├── init_db.py ├── install.md ├── license.txt ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 5c900eb00454_add_preferred_data_source_to_.py ├── oidc_auth.py ├── oidc_user.py ├── recurring_detection.py ├── requirements.txt ├── reset.py ├── session_timeout.py ├── simplefin_client.py ├── static ├── css │ ├── investment-charts.css │ ├── styles.css │ └── transaction-module.css ├── images │ ├── dddby.png │ └── dollar.png └── js │ ├── budget │ ├── budget-charts.js │ ├── budget-forms.js │ ├── budget-main.js │ ├── budget-transactions.js │ └── budget-utils.js │ ├── category_splits.js │ ├── common │ └── utils.js │ ├── comparison_tab.js │ ├── currency_helper.js │ ├── dashboard │ ├── dashboard_charts.js │ ├── debug_dashboard_charts.js │ └── docker_chart_fix.js │ ├── delete │ ├── addExpense.js │ ├── add_transaction.js │ ├── edit_transaction.js │ ├── t │ ├── transaction-form-fix.js │ ├── transaction_module.js │ └── unified_transaction_module.js │ ├── enhanced-multiselect.js │ ├── groups │ └── group_details.js │ ├── investment-charts.js │ ├── recurring-detection.js │ ├── recurring │ └── recurring_transaction.js │ ├── split_category_fix.js │ ├── stats.js │ ├── transactions.js │ ├── transactions │ ├── add_transaction.js │ ├── edit_transaction.js │ └── ui_helpers.js │ └── utils.js ├── templates ├── Untitled-1.js ├── accounts.html ├── add.html ├── admin.html ├── advanced.html ├── base.html ├── budgets.html ├── cache_management.html ├── categories.html ├── category_mappings.html ├── currencies.html ├── dashboard.html ├── dashboard_test.html ├── demo │ ├── demo_concurrent.html │ ├── demo_expired.html │ └── demo_thanks.html ├── email │ ├── monthly_report.html │ └── monthly_report.txt ├── group_details.html ├── groups.html ├── import_results.html ├── index.html ├── investments │ ├── dashboard.html │ ├── partials │ │ └── investment_details.html │ ├── portfolio_details.html │ ├── portfolios.html │ └── transactions.html ├── landing.html ├── login.html ├── manage_ignored_patterns.html ├── partials │ ├── add_transaction_form.html │ ├── comparison_tab.html │ ├── create_group_form.html │ ├── edit_transaction_form.html │ ├── investment_tab.html │ └── recurring_transaction_form.html ├── profile.html ├── recurring.html ├── reset_password.html ├── reset_password_confirm.html ├── settlements.html ├── setup_investment_api.html ├── signup.html ├── simplefin_accounts.html ├── stas_update.html ├── stats.html ├── tags.html └── transactions.html ├── update_currencies.py └── yfinance_integration_enhanced.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .github 5 | 6 | # Docker 7 | .docker 8 | Dockerfile* 9 | docker-compose* 10 | .dockerignore 11 | 12 | # Python 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | *.so 17 | .Python 18 | venv/ 19 | env/ 20 | ENV/ 21 | .env 22 | .venv 23 | 24 | # Database files 25 | *.db 26 | *.sqlite3 27 | instance/ 28 | *.db-journal 29 | 30 | # IDE settings 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | *~ 36 | 37 | # OS generated files 38 | .DS_Store 39 | .DS_Store? 40 | ._* 41 | .Spotlight-V100 42 | .Trashes 43 | ehthumbs.db 44 | Thumbs.db 45 | 46 | # Test files 47 | .coverage 48 | htmlcov/ 49 | .pytest_cache/ 50 | .tox/ 51 | 52 | # Build and distribution 53 | dist/ 54 | build/ 55 | *.egg-info/ 56 | *.egg 57 | 58 | # Documentation 59 | docs/ 60 | *.md 61 | license.txt 62 | install.md 63 | README.md 64 | 65 | # Utility scripts and non-essential files 66 | *.sh 67 | signature.sh 68 | simplefin-30-days.py 69 | simplefin_test.py 70 | 71 | 72 | # Logs 73 | *.log 74 | logs/ 75 | 76 | # Development files 77 | .temp/ 78 | .tmp/ 79 | *.pem 80 | *.key 81 | *.crt 82 | simplefin_data.json 83 | 84 | # Jupyter Notebooks 85 | .ipynb_checkpoints# 29a41de6a866d56c36aba5159f45257c 86 | 87 | 88 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database Configuration 2 | DB_USER=postgres 3 | DB_PASSWORD=your_secure_postgres_password 4 | DB_NAME=dollardollar 5 | 6 | # Application Settings 7 | SECRET_KEY=your_very_long_random_secret_key 8 | DISABLE_SIGNUPS=False 9 | DEBUG=False 10 | LOG_LEVEL=INFO 11 | LOCAL_LOGIN_DISABLE=False # Set to True to disable local login when OIDC is enabled 12 | DEVELOPMENT_MODE=False 13 | 14 | # Email Configuration 15 | MAIL_SERVER=smtp.gmail.com 16 | MAIL_PORT=587 17 | MAIL_USE_TLS=True 18 | MAIL_USE_SSL=False 19 | MAIL_USERNAME=your_email@gmail.com 20 | MAIL_PASSWORD=your_app_password 21 | MAIL_DEFAULT_SENDER=your_email@gmail.com 22 | 23 | # OIDC Configuration 24 | OIDC_ENABLED=False # Set to True to enable OIDC 25 | OIDC_CLIENT_ID=dollardollar 26 | OIDC_CLIENT_SECRET=your_oidc_client_secret 27 | OIDC_ISSUER=https://auth.domain.com 28 | # OIDC_PROVIDER_NAME=Authelia #optional, passes the provider name to login button i.e. "Login with Authelia" 29 | APP_URL=https://dollardollar.domain.com 30 | OIDC_LOGOUT_URI=https://auth.domain.com/logout 31 | # OIDC_DISCOVERY_URL=https://auth.domain.com/.well-known/openid-configuration #optional, app automatically constructs this link, set this if different from the standard# 29a41de6a866d56c36aba5159f45257c 32 | 33 | #simplefin 34 | SIMPLEFIN_ENABLED=True 35 | 36 | 37 | #investment 38 | 39 | INVESTMENT_TRACKING_ENABLED=True 40 | 41 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: harung1993 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: cCFW6gZz28 # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | on: 3 | push: 4 | branches: 5 | - "dollardollar-modular" 6 | - "main" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | 18 | - name: Login to Docker Hub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | 24 | - name: Build and push dev image 25 | if: github.ref == 'refs/heads/dollardollar-modular' 26 | uses: docker/build-push-action@v4 27 | with: 28 | context: . 29 | push: true 30 | tags: harung43/dollardollar:dev 31 | 32 | - name: Build and push main images 33 | if: github.ref == 'refs/heads/main' 34 | uses: docker/build-push-action@v4 35 | with: 36 | context: . 37 | push: true 38 | tags: harung43/dollardollar:demo,harung43/dollardollar:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecode and cache 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | 8 | # Flask instance folder 9 | instance/ 10 | 11 | # Database files 12 | *.db 13 | *.sqlite3 14 | *.db-journal 15 | 16 | # Virtual Environment 17 | venv/ 18 | env/ 19 | ENV/ 20 | .env 21 | .venv 22 | 23 | # IDE settings 24 | .idea/ 25 | .vscode/ 26 | *.swp 27 | *.swo 28 | *~ 29 | 30 | # OS generated files 31 | .DS_Store 32 | .DS_Store? 33 | ._* 34 | .Spotlight-V100 35 | .Trashes 36 | ehthumbs.db 37 | Thumbs.db 38 | 39 | # Logs and temporary files 40 | *.log 41 | logs/ 42 | .temp/ 43 | .tmp/ 44 | 45 | # Build and distribution 46 | dist/ 47 | build/ 48 | *.egg-info/ 49 | *.egg 50 | 51 | # Test coverage 52 | .coverage 53 | htmlcov/ 54 | .pytest_cache/ 55 | .tox/ 56 | 57 | # Migrations (optional - you might want to keep these) 58 | # migrations/ 59 | 60 | # Configuration and secrets 61 | *.pem 62 | *.key 63 | *.crt 64 | simplefin_data.json 65 | 66 | # Project-specific 67 | *.sh 68 | signature.sh 69 | simplefin-30-days.py 70 | simplefin_test.py 71 | 72 | 73 | # Docker 74 | .docker 75 | 76 | # Jupyter Notebooks 77 | .ipynb_checkpoints# 29a41de6a866d56c36aba5159f45257c 78 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a more specific base image that supports multiple architectures 2 | FROM --platform=$TARGETPLATFORM ubuntu:20.04 3 | 4 | ENV OPENSSL_LEGACY_PROVIDER=1 5 | 6 | # Set non-interactive installation and prevent apt from prompting 7 | ENV DEBIAN_FRONTEND=noninteractive 8 | 9 | # Fix for OpenSSL issue with digital envelope routines 10 | ENV PYTHONWARNINGS=ignore 11 | ENV OPENSSL_CONF=/etc/ssl/openssl-legacy.cnf 12 | ENV OPENSSL_ENABLE_MD5_VERIFY=1 13 | ENV NODE_OPTIONS=--openssl-legacy-provider 14 | 15 | # Install system dependencies 16 | RUN apt-get update && apt-get install -y \ 17 | python3 \ 18 | python3-pip \ 19 | python3-dev \ 20 | python3-venv \ 21 | build-essential \ 22 | curl \ 23 | libssl-dev \ 24 | postgresql-client \ 25 | libpq-dev \ 26 | && rm -rf /var/lib/apt/lists/* \ 27 | && pip install scrypt 28 | 29 | # Create OpenSSL legacy config to fix the digital envelope issue 30 | RUN mkdir -p /etc/ssl 31 | RUN echo "[openssl_init]\nlegacy = 1\nproviders = provider_sect\n\n[provider_sect]\ndefault = default_sect\nlegacy = legacy_sect\n\n[default_sect]\nactivate = 1\n\n[legacy_sect]\nactivate = 1" > /etc/ssl/openssl-legacy.cnf 32 | 33 | 34 | # Set working directory 35 | WORKDIR /app 36 | 37 | 38 | # Set up a virtual environment 39 | RUN python3 -m venv /venv 40 | ENV PATH="/venv/bin:$PATH" 41 | 42 | # Upgrade pip 43 | RUN pip install --upgrade pip 44 | 45 | 46 | # Copy requirements and install dependencies 47 | COPY requirements.txt . 48 | RUN pip install --no-cache-dir -r requirements.txt 49 | 50 | # Install gunicorn 51 | RUN pip install --no-cache-dir gunicorn==20.1.0 52 | 53 | # Copy application code 54 | COPY . . 55 | 56 | # Create patch for SSL in app.py 57 | RUN echo "import ssl\n\n# Legacy OpenSSL support\ntry:\n ssl._create_default_https_context = ssl._create_unverified_context\nexcept AttributeError:\n pass" > ssl_fix.py 58 | 59 | # Apply the patch 60 | RUN cat ssl_fix.py app.py > temp_app.py && mv temp_app.py app.py 61 | 62 | # Set environment variables 63 | ENV PYTHONDONTWRITEBYTECODE=1 64 | ENV PYTHONUNBUFFERED=1 65 | ENV FLASK_APP=app.py 66 | 67 | # Expose the port 68 | EXPOSE 5001 69 | 70 | # Use multi-stage build support 71 | ARG TARGETPLATFORM 72 | 73 | # Use the absolute path to gunicorn from the virtual environment 74 | CMD ["/venv/bin/gunicorn", "--bind", "0.0.0.0:5001", "--workers=3", "--timeout=120", "app:app"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | DollarDollar Bill Y'all logo 4 | 5 |

6 |

DollarDollar Bill Y'all

7 |
8 | license 9 | GitHub Workflow Status 10 |
11 |

An open-source, self-hosted money management platform with comprehensive expense tracking, budgeting, account synchronization, and bill-splitting features - designed for privacy, flexibility, and complete financial control.

12 |
13 |

14 | Demo 15 | | 16 | Discord 17 | | 18 | License 19 |

20 |
21 | 22 | ## 🌟 Why DollarDollar? 23 | 24 | Born from a desire to move beyond restrictive financial tracking platforms, this app empowers users with: 25 | 26 | - 🔐 **Complete control over personal financial data** 27 | - 💡 **Flexible expense splitting mechanisms** 28 | - 🏠 **Self-hosted privacy** 29 | - 🤝 **Collaborative expense management** 30 | - 🔄 **Integration with Simplefin** (auto tracking accounts and transactions) 31 | - 💰 **Budgets with notifications** 32 | - 🖥️ **Seamless integration with Unraid** for easy installation and management via Unraid templates 33 | - 💼 **Track Portfolios and investments with auto update of sticker prices 34 | 35 | 36 | ## 🚀 Features 37 | 38 | - **💰 Expense Tracking & Management** 39 | - Multi-currency support with automatic conversion 40 | - Recurring transactions with flexible scheduling 41 | - Auto-categorization with customizable rules 42 | - Transaction importing (CSV, SimpleFin) 43 | - Transaction with multi category support 44 | - Multi-card and multi-account support 45 | - Date-based expense tracking 46 | 47 | - **👥 Bill Splitting** 48 | - Multiple split methods: equal, custom amount, percentage 49 | - Group and personal expense tracking 50 | - Settlement tracking and balances 51 | - Email invitations for group members 52 | 53 | - **📊 Budgeting & Analytics** 54 | - Custom budgets with notifications 55 | - Monthly financial summaries 56 | - Expense trends visualization 57 | - Category-based spending analysis 58 | - Comprehensive balance tracking 59 | 60 | - **🏷️ Organization & Categories** 61 | - Customizable tags for expense categorization 62 | - Category hierarchies (main categories with sub-categories) 63 | - Auto-categorization based on transaction patterns 64 | - Category-based reports for tax purposes 65 | 66 | - **🔐 Security & Privacy** 67 | - Self-hosted for complete data control 68 | - Local auth + OpenID Connect (OIDC) integration 69 | - Enterprise-ready authentication with any OIDC provider 70 | - User management with password recovery 71 | - No third-party data sharing 72 | - **💼 Portfolio Management 73 | - Create and manage multiple investment portfolios 74 | - Link portfolios to accounts for automatic balance updates 75 | - Track individual investments across different portfolios 76 | - Visualize portfolio performance and distribution 77 | 78 | ## 🛠️ Getting Started 79 | 80 | ### Updating 81 | If you are encountering issues after updating/pulling the recent docker, please run: 82 | ```bash 83 | flask db migrate 84 | flask db upgrade 85 | ``` 86 | 87 | If you wish to reset the database: 88 | ```bash 89 | python reset.py 90 | ``` 91 | 92 | ### Prerequisites 93 | - Docker 94 | - Docker Compose 95 | 96 | ### Quick Installation 97 | 98 | 1. Clone the repository 99 | ```bash 100 | git clone https://github.com/harung1993/dollardollar.git 101 | cd dollardollar 102 | ``` 103 | 104 | 2. Configure environment 105 | ```bash 106 | cp .env.template .env 107 | # Edit .env with your configuration 108 | ``` 109 | 110 | 3. Launch the application 111 | ```bash 112 | docker-compose up -d 113 | ``` 114 | 115 | 4. Access the app at `http://localhost:5006` 116 | 117 | ## ⚙️ Configuration Options 118 | 119 | ### Investment Tracking (Optional) 120 | To enable Investment Tracking : 121 | ``` 122 | INVESTMENT_TRACKING_ENABLED=True 123 | ``` 124 | 125 | ### OIDC Setup (Optional) 126 | To enable OpenID Connect authentication: 127 | 128 | ``` 129 | OIDC_ENABLED=True 130 | OIDC_CLIENT_ID=your_client_id 131 | OIDC_CLIENT_SECRET=your_client_secret 132 | OIDC_PROVIDER_NAME=Your Provider Name 133 | OIDC_DISCOVERY_URL=https://your-provider/.well-known/openid-configuration 134 | ``` 135 | ### Other Optionals 136 | # Optional settings 137 | ``` 138 | LOCAL_LOGIN_DISABLE=True # Disable password logins 139 | DISABLE_SIGNUPS=True # Disable registration 140 | ``` 141 | 142 | ### Additional Configuration 143 | For detailed configuration options, see the [.env.template](https://github.com/harung1993/dollardollar/blob/main/.env.example) file. 144 | 145 | ## 📸 Screenshots 146 | 147 |
148 | Dashboard 149 | Expense Splitting 150 | Settling Splits 151 | Budgets 152 | Categories 153 | Portfolios 154 | 155 |
156 | 157 | ## 🤝 Development Approach 158 | 159 | This project explores AI-assisted open-source development: 160 | - Leveraging AI tools for rapid prototyping 161 | - Combining technological innovation with human creativity 162 | - Iterative development with large language models 163 | - Local LLMs (qwen2.5, DeepSeek-V3) 164 | - Claude AI 165 | - Human domain expertise 166 | 167 | ## 🤝 Contributing 168 | 169 | Contributions are welcome! Please check out our contributing guidelines. 170 | 171 | 1. Fork the repository 172 | 2. Create your feature branch 173 | 3. Submit a Pull Request 174 | 175 | ## 🙏 Acknowledgements 176 | 177 | - Special thanks to my wife, who endured countless late nights of coding, provided unwavering support, and maintained patience during endless debugging sessions 178 | - Thanks to JordanDalby for creating and maintaining the Unraid template 179 | - Thanks to @elmerfds for the OIDC support! 180 | 181 | ## 📜 License 182 | 183 | This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE](license.txt) file for details. 184 | 185 | This license requires anyone who runs a modified version of this software, including running it on a server as a service, to make the complete source code available to users of that service. 186 | 187 | ## 🙏 Support 188 | 189 | If you like this project and would like to support my work, you can buy me a coffee! 190 | 191 | 192 | -------------------------------------------------------------------------------- /add_column.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | # save as update_db.py 3 | from sqlalchemy import create_engine, text, inspect 4 | import os 5 | 6 | # Get database URI from environment or use default 7 | db_uri = os.environ.get('SQLALCHEMY_DATABASE_URI', 'postgresql://postgres:postgres@db:5432/dollardollar') 8 | 9 | # Connect to database 10 | engine = create_engine(db_uri) 11 | 12 | # Check if column exists and add it if it doesn't 13 | with engine.connect() as connection: 14 | inspector = inspect(engine) 15 | columns = [col['name'] for col in inspector.get_columns('users')] 16 | 17 | if 'user_color' not in columns: 18 | print("Adding missing user_color column...") 19 | try: 20 | connection.execute(text('ALTER TABLE users ADD COLUMN user_color VARCHAR(7) DEFAULT \'#15803d\'')) 21 | connection.commit() 22 | print("Successfully added user_color column!") 23 | except Exception as e: 24 | print(f"Error adding column: {e}") 25 | else: 26 | print("user_color column already exists") -------------------------------------------------------------------------------- /demo_reset.py: -------------------------------------------------------------------------------- 1 | # demo_reset.py 2 | import os 3 | import time 4 | import logging 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import sessionmaker 7 | from datetime import datetime, timedelta 8 | import schedule 9 | 10 | # Setup logging 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 14 | ) 15 | logger = logging.getLogger('demo_reset') 16 | 17 | # Get database URI from environment variable 18 | DB_URI = os.getenv('SQLALCHEMY_DATABASE_URI') 19 | RESET_INTERVAL = int(os.getenv('RESET_INTERVAL', 86400)) # Default: 24 hours 20 | 21 | def reset_demo_data(): 22 | """Reset the demo database to a clean state with sample data""" 23 | logger.info("Starting demo environment reset") 24 | 25 | try: 26 | # Create database engine 27 | engine = create_engine(DB_URI) 28 | Session = sessionmaker(bind=engine) 29 | session = Session() 30 | 31 | # Import models for database operations 32 | from app import User, Group, Expense, Budget, Category, Tag 33 | 34 | # Clean demo-specific data 35 | logger.info("Cleaning demo user data") 36 | 37 | # Get all demo users (email contains 'demo') 38 | demo_users = session.query(User).filter(User.id.like('%demo%')).all() 39 | 40 | for user in demo_users: 41 | # Delete all user's expenses 42 | logger.info(f"Deleting expenses for user: {user.id}") 43 | session.query(Expense).filter(Expense.user_id == user.id).delete() 44 | 45 | # Delete user's budgets 46 | logger.info(f"Deleting budgets for user: {user.id}") 47 | session.query(Budget).filter(Budget.user_id == user.id).delete() 48 | 49 | # Delete user's tags 50 | logger.info(f"Deleting tags for user: {user.id}") 51 | session.query(Tag).filter(Tag.user_id == user.id).delete() 52 | 53 | # Find groups created by user 54 | logger.info(f"Deleting groups for user: {user.id}") 55 | user_groups = session.query(Group).filter(Group.created_by == user.id).all() 56 | for group in user_groups: 57 | session.delete(group) 58 | 59 | # Reset demo user passwords 60 | for user in demo_users: 61 | logger.info(f"Resetting password for user: {user.id}") 62 | user.set_password('demo') 63 | 64 | # Commit changes 65 | session.commit() 66 | 67 | # Recreate demo data 68 | logger.info("Recreating demo data") 69 | from app import create_demo_data 70 | 71 | for user in demo_users: 72 | create_demo_data(user.id) 73 | 74 | logger.info("Demo environment reset completed successfully") 75 | 76 | except Exception as e: 77 | logger.error(f"Error during demo reset: {str(e)}") 78 | if session: 79 | session.rollback() 80 | finally: 81 | if session: 82 | session.close() 83 | 84 | def main(): 85 | """Main function that schedules and runs the reset job""" 86 | logger.info("Demo reset service starting") 87 | 88 | # Run reset immediately at startup 89 | reset_demo_data() 90 | 91 | # Schedule reset based on interval 92 | hours = RESET_INTERVAL / 3600 93 | logger.info(f"Scheduling reset every {hours} hours") 94 | 95 | schedule.every(RESET_INTERVAL).seconds.do(reset_demo_data) 96 | 97 | # Run the schedule loop 98 | while True: 99 | schedule.run_pending() 100 | time.sleep(60) # Check every minute 101 | 102 | if __name__ == "__main__": 103 | main() -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | web: 5 | build: . 6 | container_name: expense-tracker-dev 7 | ports: 8 | - "5001:5001" 9 | volumes: 10 | - .:/app 11 | - ./instance:/app/instance 12 | environment: 13 | - FLASK_ENV=development 14 | - FLASK_APP=app.py 15 | - FLASK_DEBUG=1 16 | command: flask run --host=0.0.0.0 --port=5001 17 | restart: always 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: harung43/dollardollar:latest 6 | platform: linux/amd64 7 | ports: 8 | - "5006:5001" 9 | environment: 10 | - SQLALCHEMY_DATABASE_URI=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} 11 | - DEVELOPMENT_MODE=False 12 | - DISABLE_SIGNUPS=${DISABLE_SIGNUPS:-False} 13 | - DEBUG=${DEBUG:-False} 14 | - LOG_LEVEL=${LOG_LEVEL:-INFO} 15 | - FLASK_APP=app.py 16 | - SECRET_KEY=${SECRET_KEY} 17 | - MAIL_SERVER=${MAIL_SERVER} 18 | - MAIL_PORT=${MAIL_PORT:-587} 19 | - MAIL_USE_TLS=${MAIL_USE_TLS:-True} 20 | - MAIL_USE_SSL=${MAIL_USE_SSL:-False} 21 | - MAIL_USERNAME=${MAIL_USERNAME} 22 | - MAIL_PASSWORD=${MAIL_PASSWORD} 23 | - MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER} 24 | - SIMPLEFIN_ENABLED=${SIMPLEFIN_ENABLED} 25 | depends_on: 26 | db: 27 | condition: service_healthy 28 | restart: unless-stopped 29 | networks: 30 | - app-network 31 | 32 | db: 33 | image: postgres:13 34 | environment: 35 | - POSTGRES_USER=${DB_USER} 36 | - POSTGRES_PASSWORD=${DB_PASSWORD} 37 | - POSTGRES_DB=${DB_NAME} 38 | volumes: 39 | - postgres_data:/var/lib/postgresql/data 40 | healthcheck: 41 | test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] 42 | interval: 5s 43 | timeout: 5s 44 | retries: 5 45 | restart: unless-stopped 46 | networks: 47 | - app-network 48 | 49 | volumes: 50 | postgres_data: 51 | 52 | networks: 53 | app-network: 54 | driver: bridge 55 | -------------------------------------------------------------------------------- /fix_currency.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | from app import app, db, Currency 3 | 4 | with app.app_context(): 5 | # Find the base currency 6 | base_currency = Currency.query.filter_by(is_base=True).first() 7 | 8 | if base_currency: 9 | print(f"Current base currency: {base_currency.code}") 10 | 11 | # Check if code needs correction 12 | if base_currency.code != 'USD': 13 | old_code = base_currency.code 14 | base_currency.code = 'USD' 15 | db.session.commit() 16 | print(f"Updated base currency code from {old_code} to USD") 17 | else: 18 | print("Base currency code is already USD, no change needed") 19 | else: 20 | print("No base currency found. Creating USD as base currency.") 21 | usd = Currency( 22 | code='USD', 23 | name='US Dollar', 24 | symbol='$', 25 | rate_to_base=1.0, 26 | is_base=True 27 | ) 28 | db.session.add(usd) 29 | db.session.commit() 30 | print("Created USD as base currency") -------------------------------------------------------------------------------- /fmp_cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | from datetime import datetime, timedelta 5 | import requests 6 | 7 | class FMPCache: 8 | """ 9 | Cache for Financial Modeling Prep API responses 10 | Reduces unnecessary API calls by storing responses locally 11 | """ 12 | 13 | def __init__(self, cache_dir='instance/cache/fmp', expire_hours=24): 14 | """ 15 | Initialize the cache 16 | 17 | Args: 18 | cache_dir: Directory to store cache files 19 | expire_hours: Hours before cache expires (default: 24 hours) 20 | """ 21 | self.cache_dir = cache_dir 22 | self.expire_seconds = expire_hours * 3600 23 | 24 | # Create cache directory if it doesn't exist 25 | if not os.path.exists(self.cache_dir): 26 | os.makedirs(self.cache_dir) 27 | 28 | # Stats for monitoring 29 | self.stats = { 30 | 'hits': 0, 31 | 'misses': 0, 32 | 'api_calls': 0 33 | } 34 | 35 | def _get_cache_filename(self, endpoint, params): 36 | """Generate a unique filename for the request""" 37 | # Sort params to ensure consistent filename for same requests with different param order 38 | params_str = json.dumps(params, sort_keys=True) 39 | # Create a hash from the endpoint and params 40 | cache_key = f"{endpoint}_{hash(params_str)}" 41 | return os.path.join(self.cache_dir, f"{cache_key.replace('/', '_')}.json") 42 | 43 | def get(self, api_url, endpoint, api_key, params=None): 44 | """ 45 | Get data from cache or API 46 | 47 | Args: 48 | api_url: Base API URL 49 | endpoint: API endpoint 50 | api_key: API key 51 | params: Additional parameters for the request (excluding API key) 52 | 53 | Returns: 54 | API response data 55 | """ 56 | if params is None: 57 | params = {} 58 | 59 | # Add API key to params 60 | request_params = params.copy() 61 | request_params['apikey'] = api_key 62 | 63 | # Check if response is in cache 64 | cache_file = self._get_cache_filename(endpoint, params) 65 | 66 | if os.path.exists(cache_file): 67 | # Read cache file 68 | with open(cache_file, 'r') as f: 69 | cache_data = json.load(f) 70 | 71 | # Check if cache is still valid 72 | if time.time() - cache_data['timestamp'] < self.expire_seconds: 73 | self.stats['hits'] += 1 74 | return cache_data['data'] 75 | 76 | # Cache miss or expired, make API request 77 | self.stats['misses'] += 1 78 | self.stats['api_calls'] += 1 79 | 80 | full_url = f"{api_url}/{endpoint}" 81 | response = requests.get(full_url, params=request_params) 82 | 83 | if response.status_code != 200: 84 | raise Exception(f"API request failed with status code {response.status_code}: {response.text}") 85 | 86 | # Parse response data 87 | data = response.json() 88 | 89 | # Save to cache 90 | cache_data = { 91 | 'timestamp': time.time(), 92 | 'data': data 93 | } 94 | 95 | with open(cache_file, 'w') as f: 96 | json.dump(cache_data, f) 97 | 98 | return data 99 | 100 | def clear_expired(self): 101 | """Clear expired cache files""" 102 | count = 0 103 | for filename in os.listdir(self.cache_dir): 104 | cache_file = os.path.join(self.cache_dir, filename) 105 | 106 | if os.path.isfile(cache_file) and cache_file.endswith('.json'): 107 | try: 108 | with open(cache_file, 'r') as f: 109 | cache_data = json.load(f) 110 | 111 | if time.time() - cache_data['timestamp'] >= self.expire_seconds: 112 | os.remove(cache_file) 113 | count += 1 114 | except (json.JSONDecodeError, KeyError): 115 | # Invalid cache file, remove it 116 | os.remove(cache_file) 117 | count += 1 118 | 119 | return count 120 | 121 | def clear_all(self): 122 | """Clear all cache files""" 123 | count = 0 124 | for filename in os.listdir(self.cache_dir): 125 | cache_file = os.path.join(self.cache_dir, filename) 126 | 127 | if os.path.isfile(cache_file) and cache_file.endswith('.json'): 128 | os.remove(cache_file) 129 | count += 1 130 | 131 | return count 132 | 133 | def get_stats(self): 134 | """Get cache stats""" 135 | total_requests = self.stats['hits'] + self.stats['misses'] 136 | hit_rate = (self.stats['hits'] / total_requests * 100) if total_requests > 0 else 0 137 | 138 | return { 139 | 'hits': self.stats['hits'], 140 | 'misses': self.stats['misses'], 141 | 'api_calls': self.stats['api_calls'], 142 | 'hit_rate': f"{hit_rate:.2f}%", 143 | 'api_calls_saved': self.stats['hits'] 144 | } -------------------------------------------------------------------------------- /init_db.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | from app import app, db 3 | 4 | def init_database(): 5 | with app.app_context(): 6 | db.create_all() 7 | print("Database tables created successfully!") 8 | 9 | if __name__ == "__main__": 10 | init_database() -------------------------------------------------------------------------------- /install.md: -------------------------------------------------------------------------------- 1 | # Installation and Usage Guide for DollarDollar Bill Y'all 2 | 3 | ## Prerequisites 4 | 5 | ### System Requirements 6 | - Docker (version 20.10 or later) 7 | - Docker Compose (version 1.29 or later) 8 | - Minimum 2GB RAM 9 | - Web browser (Chrome, Firefox, Safari, or Edge) 10 | 11 | ### Recommended Hardware 12 | - 4GB RAM 13 | - 10GB disk space 14 | - Internet connection for initial setup 15 | 16 | ### NOTE : The first user to signup will become the admin 17 | 18 | ## Installation Methods 19 | 20 | ### 1. Docker Deployment (Recommended) 21 | 22 | #### Quick Start 23 | ```bash 24 | # Clone the repository 25 | git clone https://github.com/yourusername/dollardollar.git 26 | cd dollardollar 27 | 28 | # Copy environment template 29 | cp .env.template .env 30 | 31 | # Edit .env file with your configurations 32 | nano .env 33 | 34 | # Build and run the application 35 | docker-compose up --build 36 | ``` 37 | 38 | #### Detailed Configuration 39 | 40 | 1. **Environment Variables** 41 | - `SECRET_KEY`: Generate a random, secure string 42 | - `DEVELOPMENT_MODE`: Set to `False` for production 43 | - `DISABLE_SIGNUPS`: Control user registration 44 | - Configure email settings if needed 45 | 46 | 2. **Access the Application** 47 | - Open http://localhost:5001 in your web browser 48 | - First registered user becomes the admin 49 | 50 | ### 2. Local Development Setup 51 | 52 | #### Requirements 53 | - Python 3.9+ 54 | - PostgreSQL 13+ 55 | - pip 56 | - virtualenv (recommended) 57 | 58 | ```bash 59 | # Create virtual environment 60 | python3 -m venv venv 61 | source venv/bin/activate 62 | 63 | # Install dependencies 64 | pip install -r requirements.txt 65 | 66 | # Initialize database 67 | flask db upgrade 68 | 69 | # Run the application 70 | flask run 71 | ``` 72 | 73 | ## Basic Use Cases 74 | 75 | ### 1. Adding an Expense 76 | 77 | 1. Click "Add New Expense" 78 | 2. Fill in details: 79 | - Description 80 | - Amount 81 | - Date 82 | - Card Used 83 | - Split Method (Equal/Custom/Percentage) 84 | 3. Select participants 85 | 4. Save expense 86 | 87 | ### 2. Creating a Group 88 | 89 | 1. Navigate to "Groups" 90 | 2. Click "Create Group" 91 | 3. Add group name and description 92 | 4. Invite group members 93 | 5. Start sharing expenses within the group 94 | 95 | ### 3. Settling Up 96 | 97 | 1. Go to "Settle Up" page 98 | 2. View who owes what 99 | 3. Record settlements 100 | 4. Track balance between users 101 | 102 | ## Security Considerations 103 | 104 | - Use strong, unique passwords 105 | - Enable two-factor authentication if possible 106 | - Regularly update the application 107 | - Keep your Docker and dependencies updated 108 | 109 | ## Troubleshooting 110 | 111 | ### Common Issues 112 | - Ensure Docker is running 113 | - Check container logs 114 | - Verify environment variables 115 | - Restart containers 116 | 117 | ```bash 118 | # View container logs 119 | docker-compose logs web 120 | 121 | # Restart services 122 | docker-compose down 123 | docker-compose up --build 124 | ``` 125 | 126 | ## Backup and Restore 127 | 128 | ### Database Backup 129 | ```bash 130 | # Backup PostgreSQL database 131 | docker-compose exec db pg_dump -U postgres dollardollar > backup.sql 132 | 133 | # Restore database 134 | docker-compose exec -T db psql -U postgres dollardollar < backup.sql 135 | ``` 136 | 137 | ## Upgrade Process 138 | 139 | 1. Pull latest version 140 | 2. Update dependencies 141 | 3. Run database migrations 142 | 4. Rebuild and restart containers 143 | 144 | ```bash 145 | git pull origin main 146 | docker-compose down 147 | docker-compose up --build 148 | ``` 149 | 150 | ## Contributing 151 | 152 | - Report issues on GitHub 153 | - Submit pull requests 154 | - Follow project coding standards 155 | 156 | ## License 157 | 158 | MIT License - See LICENSE file for details -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from flask import current_app 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | logger = logging.getLogger('alembic.env') 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | config.set_main_option( 24 | 'sqlalchemy.url', 25 | str(current_app.extensions['migrate'].db.get_engine().url).replace( 26 | '%', '%%')) 27 | target_metadata = current_app.extensions['migrate'].db.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, target_metadata=target_metadata, literal_binds=True 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | connectable = current_app.extensions['migrate'].db.get_engine() 75 | 76 | with connectable.connect() as connection: 77 | context.configure( 78 | connection=connection, 79 | target_metadata=target_metadata, 80 | process_revision_directives=process_revision_directives, 81 | **current_app.extensions['migrate'].configure_args 82 | ) 83 | 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | 87 | 88 | if context.is_offline_mode(): 89 | run_migrations_offline() 90 | else: 91 | run_migrations_online() 92 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/5c900eb00454_add_preferred_data_source_to_.py: -------------------------------------------------------------------------------- 1 | """Add preferred_data_source to UserApiSettings model 2 | 3 | Revision ID: 5c900eb00454 4 | Revises: 5 | Create Date: 2025-05-01 16:30:50.740718 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5c900eb00454' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('investments', sa.Column('exchange', sa.String(length=10), nullable=True)) 22 | op.add_column('investments', sa.Column('data_source', sa.String(length=20), nullable=True)) 23 | op.add_column('user_api_settings', sa.Column('preferred_data_source', sa.String(length=20), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('user_api_settings', 'preferred_data_source') 30 | op.drop_column('investments', 'data_source') 31 | op.drop_column('investments', 'exchange') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /oidc_user.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | """ 3 | OIDC User Model Extensions for DollarDollar Bill Y'all 4 | Provides OIDC integration for User model 5 | """ 6 | 7 | import json 8 | import secrets 9 | import hashlib 10 | from datetime import datetime 11 | from flask import current_app 12 | from sqlalchemy import Column, String, DateTime, Boolean 13 | 14 | def extend_user_model(db, User): 15 | """ 16 | Extends the User model with OIDC methods 17 | 18 | Args: 19 | db: SQLAlchemy database instance 20 | User: User model class to extend 21 | 22 | Returns: 23 | Updated User class with OIDC support 24 | """ 25 | # Add OIDC user creation method 26 | @classmethod 27 | def from_oidc(cls, oidc_data, provider='authelia'): 28 | """Create or update a user from OIDC data with security best practices""" 29 | # Check if user exists by OIDC ID 30 | user = cls.query.filter_by(oidc_id=oidc_data.get('sub'), oidc_provider=provider).first() 31 | 32 | # If not found, check by email, but only if we have a verified email 33 | if not user and 'email' in oidc_data: 34 | # Many providers include email_verified claim 35 | email_verified = oidc_data.get('email_verified', True) # Default to True for providers that don't send this 36 | 37 | if email_verified: 38 | user = cls.query.filter_by(id=oidc_data['email']).first() 39 | 40 | # If user exists, update OIDC details if needed 41 | if user: 42 | # Link local account with OIDC if not already linked 43 | if not user.oidc_id: 44 | user.oidc_id = oidc_data.get('sub') 45 | user.oidc_provider = provider 46 | db.session.commit() 47 | 48 | # Update any user profile information 49 | if 'name' in oidc_data and oidc_data['name'] != user.name: 50 | user.name = oidc_data['name'] 51 | db.session.commit() 52 | 53 | # Update last login time 54 | user.last_login = datetime.utcnow() 55 | db.session.commit() 56 | 57 | return user 58 | 59 | # Create new user if not found 60 | if 'email' in oidc_data: 61 | # Email is required for a new user 62 | # Generate a secure random password for the local account 63 | random_password = secrets.token_urlsafe(24) 64 | 65 | # Get the display name from OIDC data 66 | name = oidc_data.get('name', 67 | oidc_data.get('preferred_username', 68 | oidc_data['email'].split('@')[0])) 69 | 70 | # Check if this will be the first user 71 | is_first_user = cls.query.count() == 0 72 | 73 | # Create the user object 74 | user = cls( 75 | id=oidc_data['email'], 76 | name=name, 77 | oidc_id=oidc_data.get('sub'), 78 | oidc_provider=provider, 79 | is_admin=is_first_user, # Make first user admin 80 | last_login=datetime.utcnow() 81 | ) 82 | 83 | # Set the random password 84 | user.set_password(random_password) 85 | 86 | # Generate user color based on email 87 | hash_object = hashlib.md5(user.id.encode()) 88 | hash_hex = hash_object.hexdigest() 89 | r = int(hash_hex[:2], 16) 90 | g = int(hash_hex[2:4], 16) 91 | b = int(hash_hex[4:6], 16) 92 | brightness = (r * 299 + g * 587 + b * 114) / 1000 93 | if brightness > 180: 94 | r = min(int(r * 0.7), 255) 95 | g = min(int(g * 0.7), 255) 96 | b = min(int(b * 0.7), 255) 97 | user.user_color = f'#{r:02x}{g:02x}{b:02x}' 98 | 99 | # Save to database 100 | db.session.add(user) 101 | db.session.commit() 102 | from app import create_default_categories # Import at the top of the file if possible 103 | create_default_categories(user.id) 104 | # Add a log entry 105 | current_app.logger.info(f"New user created via OIDC: {user.id}, Admin: {is_first_user}") 106 | 107 | return user 108 | 109 | # If we can't create a user (no email), log and return None 110 | current_app.logger.error(f"Cannot create user from OIDC data: Missing email. Data: {json.dumps(oidc_data)}") 111 | return None 112 | 113 | # Attach the from_oidc method to the User class 114 | User.from_oidc = from_oidc 115 | 116 | return User 117 | 118 | def create_oidc_migration(directory="migrations/versions"): 119 | """ 120 | Create a migration script for adding OIDC fields to User model 121 | 122 | Args: 123 | directory: Directory to save the migration file 124 | 125 | Returns: 126 | Path to the created migration file 127 | """ 128 | import os 129 | from datetime import datetime 130 | 131 | # Create migration content 132 | migration_content = """\"\"\"Add OIDC support fields to users table 133 | 134 | Revision ID: add_oidc_fields 135 | Revises: # Will be filled automatically 136 | Create Date: {date} 137 | 138 | \"\"\" 139 | from alembic import op 140 | import sqlalchemy as sa 141 | from datetime import datetime 142 | 143 | # revision identifiers, used by Alembic. 144 | revision = 'add_oidc_fields' 145 | down_revision = None # This will be filled automatically 146 | branch_labels = None 147 | depends_on = None 148 | 149 | 150 | def upgrade(): 151 | # Add OIDC-related columns to users table 152 | op.add_column('users', sa.Column('oidc_id', sa.String(255), nullable=True)) 153 | op.add_column('users', sa.Column('oidc_provider', sa.String(50), nullable=True)) 154 | op.add_column('users', sa.Column('last_login', sa.DateTime, nullable=True)) 155 | 156 | # Create index for faster lookups by OIDC ID 157 | op.create_index(op.f('ix_users_oidc_id'), 'users', ['oidc_id'], unique=True) 158 | 159 | 160 | def upgrade_with_check(): 161 | # Check if columns already exist (for manual execution) 162 | inspector = sa.inspect(op.get_bind()) 163 | columns = [col['name'] for col in inspector.get_columns('users')] 164 | 165 | if 'oidc_id' not in columns: 166 | op.add_column('users', sa.Column('oidc_id', sa.String(255), nullable=True)) 167 | 168 | if 'oidc_provider' not in columns: 169 | op.add_column('users', sa.Column('oidc_provider', sa.String(50), nullable=True)) 170 | 171 | if 'last_login' not in columns: 172 | op.add_column('users', sa.Column('last_login', sa.DateTime, nullable=True)) 173 | 174 | # Create index if it doesn't exist 175 | indices = [idx['name'] for idx in inspector.get_indexes('users')] 176 | if 'ix_users_oidc_id' not in indices: 177 | op.create_index(op.f('ix_users_oidc_id'), 'users', ['oidc_id'], unique=True) 178 | 179 | 180 | def downgrade(): 181 | # Remove OIDC-related columns and index 182 | op.drop_index(op.f('ix_users_oidc_id'), table_name='users') 183 | op.drop_column('users', 'last_login') 184 | op.drop_column('users', 'oidc_provider') 185 | op.drop_column('users', 'oidc_id') 186 | """.format(date=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) 187 | 188 | # Ensure directory exists 189 | os.makedirs(directory, exist_ok=True) 190 | 191 | # Create migration file 192 | filename = os.path.join(directory, "add_oidc_fields.py") 193 | 194 | with open(filename, 'w') as f: 195 | f.write(migration_content) 196 | 197 | return filename -------------------------------------------------------------------------------- /recurring_detection.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from collections import defaultdict 3 | import calendar 4 | from flask import current_app 5 | from sqlalchemy import and_, or_ 6 | 7 | def detect_recurring_transactions(user_id, lookback_days=60, min_occurrences=2): 8 | """ 9 | Detect potential recurring transactions for a user based on transaction history. 10 | """ 11 | # Get the current app and its database models 12 | from flask import current_app 13 | db = current_app.extensions['sqlalchemy'].db 14 | Expense = db.session.get_bind().execute("SELECT 1").dialect.name 15 | 16 | # Use raw SQL query to avoid SQLAlchemy model dependencies 17 | from sqlalchemy import text 18 | 19 | end_date = datetime.now() 20 | start_date = end_date - timedelta(days=lookback_days) 21 | 22 | # Execute a raw SQL query to get transactions 23 | query = text(""" 24 | SELECT id, description, amount, date, currency_code, account_id, category_id, transaction_type 25 | FROM expenses 26 | WHERE user_id = :user_id 27 | AND date >= :start_date 28 | AND date <= :end_date 29 | AND recurring_id IS NULL 30 | ORDER BY date 31 | """) 32 | 33 | result = db.session.execute( 34 | query, 35 | {'user_id': user_id, 'start_date': start_date, 'end_date': end_date} 36 | ) 37 | 38 | # Convert result to a list of dictionaries for easier processing 39 | transactions = [] 40 | for row in result: 41 | transactions.append({ 42 | 'id': row.id, 43 | 'description': row.description, 44 | 'amount': row.amount, 45 | 'date': row.date, 46 | 'currency_code': row.currency_code, 47 | 'account_id': row.account_id, 48 | 'category_id': row.category_id, 49 | 'transaction_type': row.transaction_type 50 | }) 51 | 52 | # Group transactions by name + amount (potential recurrence key) 53 | transaction_groups = defaultdict(list) 54 | 55 | for transaction in transactions: 56 | # Create a composite key of description and amount 57 | key = f"{transaction['description'].strip().lower()}_{round(transaction['amount'], 2)}" 58 | transaction_groups[key].append(transaction) 59 | 60 | # Find recurring patterns 61 | recurring_candidates = [] 62 | 63 | for key, group in transaction_groups.items(): 64 | # Only consider groups with multiple occurrences 65 | if len(group) < min_occurrences: 66 | continue 67 | 68 | # Sort transactions by date 69 | sorted_transactions = sorted(group, key=lambda x: x['date']) 70 | 71 | # Analyze time intervals between transactions 72 | intervals = [] 73 | for i in range(1, len(sorted_transactions)): 74 | delta = (sorted_transactions[i]['date'] - sorted_transactions[i-1]['date']).days 75 | intervals.append(delta) 76 | 77 | # Skip if no intervals (only one transaction) 78 | if not intervals: 79 | continue 80 | 81 | # Calculate average interval 82 | avg_interval = sum(intervals) / len(intervals) 83 | 84 | # Determine frequency based on average interval 85 | frequency = determine_frequency(avg_interval) 86 | 87 | # Skip if frequency couldn't be determined 88 | if not frequency: 89 | continue 90 | 91 | # Check consistency of intervals for higher confidence 92 | interval_consistency = calculate_interval_consistency(intervals) 93 | 94 | # Last transaction date 95 | last_transaction = sorted_transactions[-1] 96 | 97 | # Calculate next expected date 98 | next_date = calculate_next_occurrence(last_transaction['date'], frequency) 99 | 100 | # Only include if consistency is reasonable 101 | if interval_consistency >= 0.7: # 70% consistency threshold 102 | # Get sample transaction for details 103 | sample = sorted_transactions[0] 104 | 105 | recurring_candidates.append({ 106 | 'description': sample['description'], 107 | 'amount': sample['amount'], 108 | 'currency_code': sample['currency_code'], 109 | 'frequency': frequency, 110 | 'account_id': sample['account_id'], 111 | 'category_id': sample['category_id'], 112 | 'transaction_type': sample['transaction_type'], 113 | 'confidence': min(interval_consistency * 100, 98), 114 | 'occurrences': len(sorted_transactions), 115 | 'last_date': last_transaction['date'], 116 | 'next_date': next_date, 117 | 'avg_interval': round(avg_interval, 1), 118 | 'transaction_ids': [t['id'] for t in sorted_transactions] 119 | }) 120 | 121 | # Sort by confidence (highest first) 122 | recurring_candidates.sort(key=lambda x: x['confidence'], reverse=True) 123 | 124 | return recurring_candidates 125 | 126 | 127 | def determine_frequency(avg_interval): 128 | """Determine the likely frequency based on average interval in days""" 129 | if 25 <= avg_interval <= 35: 130 | return 'monthly' 131 | elif 6 <= avg_interval <= 8: 132 | return 'weekly' 133 | elif 13 <= avg_interval <= 16: 134 | return 'biweekly' 135 | elif 85 <= avg_interval <= 95: 136 | return 'quarterly' 137 | elif 350 <= avg_interval <= 380: 138 | return 'yearly' 139 | elif avg_interval <= 3: 140 | return 'daily' 141 | else: 142 | return None # Can't determine frequency 143 | 144 | 145 | def calculate_interval_consistency(intervals): 146 | """ 147 | Calculate how consistent the intervals are. 148 | Returns a value between 0 and 1, where 1 is perfectly consistent. 149 | """ 150 | if not intervals: 151 | return 0 152 | 153 | # If only one interval, it's consistent by definition 154 | if len(intervals) == 1: 155 | return 0.95 # High but not perfect confidence 156 | 157 | # Calculate mean and standard deviation 158 | mean_interval = sum(intervals) / len(intervals) 159 | variance = sum((x - mean_interval) ** 2 for x in intervals) / len(intervals) 160 | std_deviation = variance ** 0.5 161 | 162 | # Calculate coefficient of variation (lower is more consistent) 163 | if mean_interval == 0: 164 | return 0 165 | 166 | cv = std_deviation / mean_interval 167 | 168 | # Convert to consistency score (1 - normalized CV) 169 | # If CV is greater than 0.5, consistency drops quickly 170 | if cv > 0.5: 171 | return max(0, 1 - (cv * 1.5)) 172 | else: 173 | return max(0, 1 - cv) 174 | 175 | 176 | def calculate_next_occurrence(last_date, frequency): 177 | """Calculate the next expected occurrence based on frequency""" 178 | if frequency == 'daily': 179 | return last_date + timedelta(days=1) 180 | elif frequency == 'weekly': 181 | return last_date + timedelta(weeks=1) 182 | elif frequency == 'biweekly': 183 | return last_date + timedelta(weeks=2) 184 | elif frequency == 'monthly': 185 | # Handle month rollover properly 186 | month = last_date.month + 1 187 | year = last_date.year 188 | 189 | if month > 12: 190 | month = 1 191 | year += 1 192 | 193 | # Handle different days in months 194 | day = min(last_date.day, calendar.monthrange(year, month)[1]) 195 | 196 | return last_date.replace(year=year, month=month, day=day) 197 | elif frequency == 'quarterly': 198 | # Add three months 199 | month = last_date.month + 3 200 | year = last_date.year 201 | 202 | if month > 12: 203 | month -= 12 204 | year += 1 205 | 206 | # Handle different days in months 207 | day = min(last_date.day, calendar.monthrange(year, month)[1]) 208 | 209 | return last_date.replace(year=year, month=month, day=day) 210 | elif frequency == 'yearly': 211 | # Add a year 212 | return last_date.replace(year=last_date.year + 1) 213 | else: 214 | # Default fallback 215 | return last_date + timedelta(days=30) 216 | 217 | 218 | def create_recurring_expense_from_detection(user_id, candidate, start_date=None): 219 | """ 220 | Create a RecurringExpense from a detected candidate 221 | """ 222 | from flask import current_app 223 | from app import RecurringExpense 224 | 225 | if start_date is None: 226 | start_date = datetime.now() 227 | 228 | # Create the recurring expense 229 | recurring = RecurringExpense( 230 | description=candidate['description'], 231 | amount=candidate['amount'], 232 | card_used="Auto-detected", 233 | split_method='equal', 234 | paid_by=user_id, 235 | user_id=user_id, 236 | frequency=candidate['frequency'], 237 | start_date=start_date, 238 | active=False, 239 | currency_code=candidate.get('currency_code'), 240 | category_id=candidate.get('category_id'), 241 | account_id=candidate.get('account_id'), 242 | transaction_type=candidate['transaction_type'] 243 | ) 244 | 245 | return recurring -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==2.2.5 2 | flask-sqlalchemy==2.5.1 3 | flask-login==0.6.3 4 | flask-migrate==4.1.0 5 | flask-mail==0.10.0 6 | python-dotenv==1.0.1 7 | gunicorn==23.0.0 8 | sqlalchemy==1.4.54 9 | werkzeug==2.2.3 10 | psycopg2-binary==2.9.9 11 | requests==2.28.2 12 | # OIDC Authentication 13 | oauthlib==3.2.2 14 | requests-oauthlib==1.3.1 15 | PyJWT==2.8.0 16 | cryptography==41.0.5 17 | pyOpenSSL==23.2.0 18 | flask-apscheduler 19 | pytz>=2021.1 20 | tzdata>=2021.1 21 | Flask-Migrate>=3.1.0 22 | Flask-SQLAlchemy>=2.5.1 23 | alembic>=1.7.5 24 | 25 | psycopg2-binary>=2.9.1 # For PostgreSQL 26 | # 29a41de6a866d56c36aba5159f45257c 27 | -------------------------------------------------------------------------------- /reset.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | #!/usr/bin/env python 3 | """ 4 | This script completely resets the database by deleting the existing database file 5 | and creating a new empty one with the current schema. 6 | """ 7 | 8 | import os 9 | import sys 10 | from app import app, db 11 | 12 | def reset_database(): 13 | """Delete existing database and create a new empty one with the current schema""" 14 | try: 15 | print("Starting database reset...") 16 | 17 | # Enter application context 18 | with app.app_context(): 19 | # Get the database URI 20 | db_uri = app.config['SQLALCHEMY_DATABASE_URI'] 21 | print(f"Database URI: {db_uri}") 22 | 23 | # Create all tables with the current schema 24 | db.drop_all() 25 | print("Dropped all tables.") 26 | 27 | db.create_all() 28 | print("Created new database with current schema.") 29 | 30 | print("\nDatabase has been successfully reset!") 31 | print("The database is now empty. The first user to sign up will automatically become an admin.") 32 | 33 | except Exception as e: 34 | print(f"Error: {str(e)}") 35 | sys.exit(1) 36 | 37 | if __name__ == "__main__": 38 | # Auto-confirm when running in Docker 39 | if os.environ.get('DOCKER_ENV') == 'true' or not sys.stdin.isatty(): 40 | reset_database() 41 | else: 42 | print("DollarDollar Database Reset Utility") 43 | print("---------------------------------") 44 | print("WARNING: This will delete all data in your database!") 45 | confirm = input("Are you sure you want to proceed? (y/n): ").lower() 46 | 47 | if confirm == 'y': 48 | reset_database() 49 | else: 50 | print("Database reset cancelled. No changes were made.") -------------------------------------------------------------------------------- /session_timeout.py: -------------------------------------------------------------------------------- 1 | # session_timeout.py 2 | from datetime import datetime, timedelta 3 | from functools import wraps 4 | from flask import session, redirect, url_for, flash, request 5 | from flask_login import logout_user, current_user 6 | import threading 7 | 8 | class DemoTimeout: 9 | """ 10 | Middleware to enforce demo user session timeouts 11 | """ 12 | # Class-level variable to track active demo sessions 13 | _active_demo_sessions = {} 14 | _session_lock = threading.Lock() 15 | 16 | def __init__(self, app=None, timeout_minutes=10, demo_users=None, max_concurrent_sessions=5): 17 | self.timeout_minutes = timeout_minutes 18 | self.demo_users = demo_users or [ 19 | 'demo@example.com', 20 | 'demo1@example.com', 21 | 'demo2@example.com' 22 | ] 23 | self.max_concurrent_sessions = max_concurrent_sessions 24 | 25 | if app is not None: 26 | self.init_app(app) 27 | 28 | def init_app(self, app): 29 | """Initialize with Flask application""" 30 | app.before_request(self.check_session_timeout) 31 | app.after_request(self.update_last_active) 32 | 33 | # Add configuration values 34 | app.config.setdefault('DEMO_TIMEOUT_MINUTES', self.timeout_minutes) 35 | app.config.setdefault('DEMO_USERS', self.demo_users) 36 | app.config.setdefault('MAX_CONCURRENT_DEMO_SESSIONS', self.max_concurrent_sessions) 37 | 38 | # Make demo status checker available in templates 39 | @app.context_processor 40 | def inject_demo_status(): 41 | return { 42 | 'is_demo_user': self.is_demo_user, 43 | 'get_remaining_time': self.get_remaining_time, 44 | 'get_active_demo_sessions': self.get_active_demo_sessions 45 | } 46 | 47 | # Store the DemoTimeout instance in the app extensions 48 | app.extensions['demo_timeout'] = self 49 | 50 | def register_demo_session(self, user_id): 51 | """ 52 | Register a new demo session, return True if successful 53 | """ 54 | with self._session_lock: 55 | # Clean up expired sessions first 56 | current_time = datetime.utcnow() 57 | 58 | # Remove expired sessions 59 | self._active_demo_sessions = { 60 | uid: session_data for uid, session_data in self._active_demo_sessions.items() 61 | if current_time < datetime.fromtimestamp(session_data['start_time']) + timedelta(minutes=self.timeout_minutes) 62 | } 63 | 64 | # Check current session count 65 | if len(self._active_demo_sessions) >= self.max_concurrent_sessions: 66 | return False 67 | 68 | # Register new session 69 | self._active_demo_sessions[user_id] = { 70 | 'start_time': datetime.utcnow().timestamp(), 71 | 'ip_address': request.remote_addr 72 | } 73 | return True 74 | 75 | def unregister_demo_session(self, user_id): 76 | """ 77 | Unregister a demo session 78 | """ 79 | with self._session_lock: 80 | if user_id in self._active_demo_sessions: 81 | del self._active_demo_sessions[user_id] 82 | 83 | def get_active_demo_sessions(self): 84 | """ 85 | Get the number of currently active demo sessions 86 | """ 87 | with self._session_lock: 88 | # Clean up expired sessions first 89 | current_time = datetime.utcnow() 90 | self._active_demo_sessions = { 91 | uid: session_data for uid, session_data in self._active_demo_sessions.items() 92 | if current_time < datetime.fromtimestamp(session_data['start_time']) + timedelta(minutes=self.timeout_minutes) 93 | } 94 | return len(self._active_demo_sessions) 95 | 96 | def check_session_timeout(self): 97 | """Check if the demo session has expired""" 98 | # Skip for static resources and login/logout pages 99 | if (not request.path.startswith('/static') and 100 | request.path not in ['/login', '/logout', '/demo', '/'] and 101 | current_user.is_authenticated and 102 | self.is_demo_user(current_user.id)): 103 | 104 | # Check if session start time exists 105 | if 'demo_start_time' not in session: 106 | session['demo_start_time'] = datetime.utcnow().timestamp() 107 | 108 | # Check if session has expired 109 | start_time = datetime.fromtimestamp(session['demo_start_time']) 110 | if datetime.utcnow() > start_time + timedelta(minutes=self.timeout_minutes): 111 | # Session expired, clean up 112 | self.unregister_demo_session(current_user.id) 113 | 114 | # Reset demo data 115 | from app import reset_demo_data # Import reset function 116 | reset_demo_data(current_user.id) 117 | 118 | # Log out user 119 | logout_user() 120 | session.clear() 121 | flash('Your demo session has expired. Thank you for trying our application!') 122 | return redirect(url_for('demo_login')) 123 | 124 | def update_last_active(self, response): 125 | """Update the last active time after each request""" 126 | user_id = None 127 | if current_user.is_authenticated: 128 | try: 129 | user_id = current_user.id 130 | except: 131 | # Handle detached user case 132 | return response 133 | if current_user.is_authenticated and self.is_demo_user(current_user.id): 134 | session['last_active'] = datetime.utcnow().timestamp() 135 | return response 136 | 137 | def is_demo_user(self, user_id): 138 | """Check if the current user is a demo user""" 139 | if not user_id: 140 | return False 141 | 142 | # Check if user is in demo users list or has a demo email pattern 143 | return (user_id in self.demo_users or 144 | user_id in ['demo@example.com', 'demo1@example.com', 'demo2@example.com'] or 145 | user_id.endswith('@demo.com') or 146 | 'demo' in user_id.lower()) 147 | 148 | def get_remaining_time(self): 149 | """Get the remaining time for the demo session in seconds""" 150 | if not current_user.is_authenticated or not self.is_demo_user(current_user.id): 151 | return None 152 | 153 | if 'demo_start_time' not in session: 154 | return self.timeout_minutes * 60 155 | 156 | start_time = datetime.fromtimestamp(session['demo_start_time']) 157 | end_time = start_time + timedelta(minutes=self.timeout_minutes) 158 | remaining = (end_time - datetime.utcnow()).total_seconds() 159 | 160 | return max(0, int(remaining)) 161 | 162 | # Decorator for routes that are time-limited in demo mode 163 | def demo_time_limited(f): 164 | @wraps(f) 165 | def decorated_function(*args, **kwargs): 166 | # If user is a demo user and session is expired, redirect to login 167 | if current_user.is_authenticated and hasattr(current_user, 'id'): 168 | from flask import current_app 169 | demo_timeout = current_app.extensions.get('demo_timeout') 170 | 171 | if demo_timeout and demo_timeout.is_demo_user(current_user.id): 172 | # Get demo timeout from app config 173 | timeout_minutes = current_app.config.get('DEMO_TIMEOUT_MINUTES', 10) 174 | 175 | # Check if session has expired 176 | if 'demo_start_time' in session: 177 | start_time = datetime.fromtimestamp(session['demo_start_time']) 178 | if datetime.utcnow() > start_time + timedelta(minutes=timeout_minutes): 179 | # Reset demo data 180 | from app import reset_demo_data 181 | reset_demo_data(current_user.id) 182 | 183 | # Log out user 184 | logout_user() 185 | session.clear() 186 | flash('Your demo session has expired. Thank you for trying our application!') 187 | return redirect(url_for('demo_login')) 188 | 189 | return f(*args, **kwargs) 190 | return decorated_function -------------------------------------------------------------------------------- /static/css/investment-charts.css: -------------------------------------------------------------------------------- 1 | /* Investment Tab Specific Styles */ 2 | .chart-loading-overlay { 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | z-index: 10; 13 | border-radius: 0.375rem; 14 | } 15 | 16 | /* Investment period button styles */ 17 | .investment-period-btn { 18 | font-size: 0.85rem; 19 | padding: 0.25rem 0.5rem; 20 | transition: all 0.2s ease; 21 | } 22 | 23 | .investment-period-btn.btn-primary { 24 | background: linear-gradient(135deg, #10b981, #34d399); 25 | border: none; 26 | box-shadow: 0 2px 5px rgba(16, 185, 129, 0.3); 27 | } 28 | 29 | /* Card styling for investment tab */ 30 | #investment .card { 31 | transition: transform 0.3s ease, box-shadow 0.3s ease; 32 | border-radius: 12px; 33 | overflow: hidden; 34 | background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8)); 35 | } 36 | 37 | #investment .card-header { 38 | background: rgba(15, 23, 42, 0.7); 39 | border-bottom: 1px solid rgba(148, 163, 184, 0.2); 40 | padding: 1rem; 41 | } 42 | 43 | #investment .card-body { 44 | padding: 1.25rem; 45 | } 46 | 47 | #investment .card-footer { 48 | background: rgba(15, 23, 42, 0.7); 49 | border-top: 1px solid rgba(148, 163, 184, 0.2); 50 | padding: 1rem; 51 | } 52 | 53 | /* Investment metrics */ 54 | #investment .metric-card { 55 | padding: 1rem; 56 | border-radius: 0.5rem; 57 | background-color: rgba(15, 23, 42, 0.3); 58 | border: 1px solid rgba(148, 163, 184, 0.2); 59 | transition: transform 0.3s ease; 60 | } 61 | 62 | #investment .metric-card:hover { 63 | transform: translateY(-2px); 64 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2); 65 | } 66 | 67 | .gauge-container { 68 | display: flex; 69 | flex-direction: column; 70 | align-items: center; 71 | justify-content: center; 72 | padding: 0.5rem; 73 | } 74 | 75 | .gauge-value { 76 | font-size: 1.5rem; 77 | font-weight: bold; 78 | color: white; 79 | } 80 | 81 | .gauge-info { 82 | margin-top: 0.5rem; 83 | text-align: center; 84 | font-size: 0.75rem; 85 | } 86 | 87 | /* Table styles */ 88 | #investment .table { 89 | color: rgba(255, 255, 255, 0.8); 90 | margin-bottom: 0; 91 | } 92 | 93 | #investment .table thead th { 94 | color: rgba(255, 255, 255, 0.6); 95 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 96 | font-size: 0.85rem; 97 | text-transform: uppercase; 98 | letter-spacing: 0.05em; 99 | } 100 | 101 | #investment .table tbody tr { 102 | transition: background-color 0.2s; 103 | border-bottom: 1px solid rgba(255, 255, 255, 0.05); 104 | } 105 | 106 | #investment .table tbody tr:hover { 107 | background-color: rgba(255, 255, 255, 0.05); 108 | } 109 | 110 | #investment .table td, 111 | #investment .table th { 112 | padding: 0.75rem 1rem; 113 | vertical-align: middle; 114 | } 115 | 116 | /* Legend styles */ 117 | .legend-container { 118 | max-height: 200px; 119 | overflow-y: auto; 120 | margin-top: 1rem; 121 | padding-right: 0.5rem; 122 | } 123 | 124 | .legend-container::-webkit-scrollbar { 125 | width: 5px; 126 | } 127 | 128 | .legend-container::-webkit-scrollbar-track { 129 | background: rgba(255, 255, 255, 0.05); 130 | border-radius: 10px; 131 | } 132 | 133 | .legend-container::-webkit-scrollbar-thumb { 134 | background: rgba(255, 255, 255, 0.2); 135 | border-radius: 10px; 136 | } 137 | 138 | /* No data message */ 139 | .no-data-message { 140 | text-align: center; 141 | padding: 1.5rem; 142 | background-color: rgba(15, 23, 42, 0.5); 143 | border-radius: 0.5rem; 144 | margin: 1rem 0; 145 | } 146 | 147 | /* Insights card styles */ 148 | #investmentInsightsCard .card-body { 149 | padding: 1.25rem; 150 | } 151 | 152 | #investmentInsightsCard .card { 153 | background-color: rgba(15, 23, 42, 0.5); 154 | transition: transform 0.2s ease; 155 | } 156 | 157 | #investmentInsightsCard .card:hover { 158 | transform: translateY(-2px); 159 | } 160 | 161 | /* Progress bar styling */ 162 | .progress { 163 | height: 6px; 164 | background-color: rgba(255, 255, 255, 0.1); 165 | border-radius: 3px; 166 | overflow: hidden; 167 | } 168 | 169 | .progress-bar { 170 | transition: width 0.6s ease; 171 | } 172 | 173 | /* Toast styling */ 174 | .toast-container { 175 | z-index: 1070; 176 | } 177 | 178 | .toast { 179 | background-color: rgba(15, 23, 42, 0.9); 180 | backdrop-filter: blur(5px); 181 | } 182 | 183 | /* Refresh button */ 184 | #refreshInvestmentsBtn { 185 | transition: background-color 0.2s, transform 0.2s; 186 | } 187 | 188 | #refreshInvestmentsBtn:hover { 189 | background-color: rgba(16, 185, 129, 0.3) !important; 190 | transform: translateY(-2px); 191 | } 192 | 193 | #refreshInvestmentsBtn:disabled { 194 | opacity: 0.7; 195 | cursor: not-allowed; 196 | } 197 | 198 | /* Responsive adjustments */ 199 | @media (max-width: 767.98px) { 200 | #investment .row > div { 201 | margin-bottom: 1rem; 202 | } 203 | 204 | #investment .card-header { 205 | padding: 0.75rem; 206 | } 207 | 208 | #investment .card-body { 209 | padding: 1rem; 210 | } 211 | 212 | .gauge-value { 213 | font-size: 1.25rem; 214 | } 215 | 216 | .chart-container { 217 | height: 250px !important; 218 | } 219 | } -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Slide panel styling */ 2 | .slide-panel { 3 | position: fixed; 4 | top: 0; 5 | right: -500px; 6 | width: 500px; 7 | height: 100vh; 8 | background-color: #1f2937; 9 | box-shadow: -5px 0 15px rgba(0, 0, 0, 0.3); 10 | z-index: 1050; 11 | overflow-y: auto; 12 | transition: right 0.3s ease-out; 13 | } 14 | 15 | .slide-panel.active { 16 | right: 0; 17 | } 18 | 19 | .slide-panel-header { 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | padding: 1rem; 24 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 25 | } 26 | 27 | .slide-panel-content { 28 | padding: 1rem; 29 | } 30 | 31 | .slide-panel-overlay { 32 | position: fixed; 33 | top: 0; 34 | left: 0; 35 | width: 100vw; 36 | height: 100vh; 37 | background-color: rgba(0, 0, 0, 0.5); 38 | z-index: 1040; 39 | opacity: 0; 40 | visibility: hidden; 41 | transition: opacity 0.3s ease-out; 42 | } 43 | 44 | .slide-panel-overlay.active { 45 | opacity: 1; 46 | visibility: visible; 47 | } 48 | 49 | /* Custom multiselect styling */ 50 | .custom-multiselect-wrapper { 51 | max-height: 200px; 52 | overflow-y: auto; 53 | border: 1px solid #444; 54 | border-radius: 0.25rem; 55 | background-color: #2d2d2d; 56 | } 57 | 58 | .custom-multiselect-item { 59 | padding: 0.5rem 1rem; 60 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 61 | cursor: pointer; 62 | } 63 | 64 | .custom-multiselect-item:hover { 65 | background-color: rgba(255, 255, 255, 0.1); 66 | } -------------------------------------------------------------------------------- /static/css/transaction-module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Transaction Module CSS 3 | * Streamlined styles for transaction functionality 4 | */ 5 | 6 | /* Base slide panel styles with GPU acceleration */ 7 | .slide-panel { 8 | position: fixed; 9 | top: 0; 10 | right: -100%; 11 | width: 90%; 12 | max-width: 550px; 13 | height: 100%; 14 | background-color: #212529; 15 | z-index: 1050; 16 | transform: translateZ(0); /* Force GPU acceleration */ 17 | will-change: right; /* Hint for browser optimization */ 18 | transition: right 0.25s ease-out; 19 | box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2); 20 | display: flex; 21 | flex-direction: column; 22 | overflow: hidden; /* Prevent content overflow */ 23 | } 24 | 25 | .slide-panel.active { 26 | right: 0; 27 | } 28 | 29 | .slide-panel-header { 30 | padding: 1rem; 31 | border-bottom: 1px solid #343a40; 32 | display: flex; 33 | justify-content: space-between; 34 | align-items: center; 35 | flex-shrink: 0; 36 | } 37 | 38 | .slide-panel-content { 39 | flex-grow: 1; 40 | overflow-y: auto; 41 | padding: 1rem; 42 | overscroll-behavior: contain; /* Prevent scroll chaining */ 43 | -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ 44 | } 45 | 46 | .slide-panel-overlay { 47 | position: fixed; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | height: 100%; 52 | background-color: rgba(0, 0, 0, 0.5); 53 | z-index: 1040; 54 | opacity: 0; 55 | visibility: hidden; 56 | will-change: opacity; /* Hint for browser optimization */ 57 | transition: opacity 0.25s ease-out, visibility 0.25s ease-out; 58 | backdrop-filter: blur(2px); /* Visual depth, disable for low-end devices */ 59 | } 60 | 61 | .slide-panel-overlay.active { 62 | opacity: 1; 63 | visibility: visible; 64 | } 65 | 66 | /* Form element optimizations */ 67 | .form-control, .form-select, .btn { 68 | transition: background-color 0.15s ease-out, border-color 0.15s ease-out; 69 | } 70 | 71 | /* Category split styles */ 72 | #category_splits_container, 73 | #edit_custom_split_container, 74 | #custom_split_container { 75 | opacity: 0; 76 | max-height: 0; 77 | overflow: hidden; 78 | transition: opacity 0.25s ease, max-height 0.25s ease; 79 | } 80 | 81 | #category_splits_container.visible, 82 | #edit_custom_split_container.visible, 83 | #custom_split_container.visible { 84 | opacity: 1; 85 | max-height: 1000px; /* Large enough to contain content */ 86 | } 87 | 88 | /* Split rows with optimized animations */ 89 | .split-row { 90 | transition: opacity 0.15s ease, transform 0.15s ease; 91 | transform-origin: center top; 92 | } 93 | 94 | .split-row.removing { 95 | opacity: 0; 96 | transform: translateX(10px); 97 | } 98 | 99 | /* Badge animations */ 100 | .badge { 101 | transition: background-color 0.15s ease; 102 | } 103 | 104 | /* Form sections (expense/income/transfer) */ 105 | .edit-expense-only-fields, 106 | .expense-only-fields, 107 | #edit_to_account_container, 108 | #to_account_container { 109 | display: none; 110 | opacity: 0; 111 | max-height: 0; 112 | overflow: hidden; 113 | transition: opacity 0.25s ease, max-height 0.25s ease; 114 | } 115 | 116 | .edit-expense-only-fields.visible, 117 | .expense-only-fields.visible, 118 | #edit_to_account_container.visible, 119 | #to_account_container.visible { 120 | display: block; 121 | opacity: 1; 122 | max-height: 2000px; /* Large enough for all content */ 123 | } 124 | 125 | /* Custom multi-select styles with performance optimizations */ 126 | .custom-multi-select-container { 127 | position: relative; 128 | } 129 | 130 | .custom-multi-select-display { 131 | cursor: pointer; 132 | min-height: 38px; 133 | white-space: normal; 134 | display: flex; 135 | flex-wrap: wrap; 136 | align-items: center; 137 | gap: 4px; 138 | padding: 6px 10px; 139 | transition: border-color 0.15s ease-out; 140 | } 141 | 142 | .custom-multi-select-dropdown { 143 | position: absolute; 144 | top: 100%; 145 | left: 0; 146 | width: 100%; 147 | max-height: 250px; 148 | overflow-y: auto; 149 | z-index: 1050; 150 | border: 1px solid #444; 151 | border-radius: 0.25rem; 152 | padding: 8px; 153 | margin-top: 2px; 154 | background-color: #2d2d2d; 155 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 156 | transform: translateY(-10px); 157 | opacity: 0; 158 | transition: transform 0.2s ease, opacity 0.2s ease; 159 | pointer-events: none; 160 | } 161 | 162 | .custom-multi-select-dropdown.visible { 163 | transform: translateY(0); 164 | opacity: 1; 165 | pointer-events: auto; 166 | } 167 | 168 | .custom-multi-select-option { 169 | display: flex; 170 | align-items: center; 171 | padding: 6px 10px; 172 | cursor: pointer; 173 | color: #fff; 174 | border-radius: 0.25rem; 175 | transition: background-color 0.15s ease; 176 | } 177 | 178 | .custom-multi-select-option:hover { 179 | background-color: #3d4a5c; 180 | } 181 | 182 | /* Category split details in transaction list */ 183 | .split-toggle { 184 | cursor: pointer; 185 | padding: 2px 6px; 186 | border-radius: 4px; 187 | display: inline-flex; 188 | align-items: center; 189 | justify-content: center; 190 | transition: background-color 0.15s ease; 191 | } 192 | 193 | .split-toggle:hover { 194 | background-color: rgba(255, 255, 255, 0.1); 195 | } 196 | 197 | .split-categories-detail { 198 | max-height: 0; 199 | overflow: hidden; 200 | opacity: 0; 201 | transition: max-height 0.3s ease, opacity 0.3s ease; 202 | } 203 | 204 | .split-categories-detail.visible { 205 | max-height: 500px; 206 | opacity: 1; 207 | } 208 | 209 | /* Optimized toast styling */ 210 | .toast { 211 | background-color: rgba(33, 37, 41, 0.95) !important; 212 | backdrop-filter: blur(4px); 213 | border-radius: 8px !important; 214 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; 215 | overflow: hidden; 216 | } 217 | 218 | /* Helper class for smooth transitions */ 219 | .transition-opacity { 220 | transition: opacity 0.25s ease; 221 | } 222 | 223 | /* Mobile optimizations */ 224 | @media (max-width: 576px) { 225 | .slide-panel { 226 | width: 100%; 227 | max-width: none; 228 | } 229 | 230 | /* Simplify animations on mobile */ 231 | .slide-panel-overlay { 232 | backdrop-filter: none; 233 | } 234 | 235 | /* Optimize for touch */ 236 | .custom-multi-select-option { 237 | padding: 10px; 238 | } 239 | 240 | .btn { 241 | min-height: 44px; /* Better touch targets */ 242 | } 243 | } 244 | 245 | /* Fix for category names in transaction list */ 246 | .category-name-text { 247 | color: white !important; 248 | -webkit-text-fill-color: white !important; 249 | } 250 | 251 | /* Print styles - hide unnecessary elements */ 252 | @media print { 253 | .slide-panel, .slide-panel-overlay, .toast-container { 254 | display: none !important; 255 | } 256 | } -------------------------------------------------------------------------------- /static/images/dddby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harung1993/dollardollar/15260bec782f026bb714fa271c70e848da624b11/static/images/dddby.png -------------------------------------------------------------------------------- /static/images/dollar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harung1993/dollardollar/15260bec782f026bb714fa271c70e848da624b11/static/images/dollar.png -------------------------------------------------------------------------------- /static/js/budget/budget-main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Budget page main initialization 3 | */ 4 | 5 | import { addCardHoverEffects } from '../common/utils.js'; 6 | import { initializeDonutChart, initializeBarChart } from './budget-charts.js'; 7 | import { setupBudgetTableInteraction, updateBudgetProgressBars, resetBudgetSelection } from './budget-utils.js'; 8 | import { toggleBudgetForm } from './budget-forms.js'; 9 | import { toggleTransactionDetails, editTransaction, confirmDeleteTransaction } from './budget-transactions.js'; 10 | 11 | /** 12 | * Initialize the budget page 13 | */ 14 | function initializeBudgetPage() { 15 | // Initialize today's date for the start date field 16 | const today = new Date().toISOString().split('T')[0]; 17 | const startDateInput = document.getElementById('start_date'); 18 | if (startDateInput) { 19 | startDateInput.value = today; 20 | } 21 | 22 | // Add toggle for budget form 23 | const toggleButton = document.getElementById('toggleBudgetForm'); 24 | if (toggleButton) { 25 | toggleButton.addEventListener('click', toggleBudgetForm); 26 | } 27 | 28 | // Initialize charts 29 | initializeDonutChart(); 30 | initializeBarChart(); 31 | 32 | // Make budget rows clickable 33 | setupBudgetTableInteraction(); 34 | 35 | // Add refresh button 36 | addRefreshButton(); 37 | 38 | // Initialize reset button 39 | const resetButton = document.getElementById('reset-budget-selection'); 40 | if (resetButton) { 41 | resetButton.addEventListener('click', resetBudgetSelection); 42 | } 43 | 44 | // Add hover effects to all cards 45 | addCardHoverEffects(); 46 | 47 | // Add auto-refresh interval (every 30 seconds) 48 | setInterval(updateBudgetProgressBars, 30000); 49 | 50 | // Expose functions needed by inline event handlers 51 | window.toggleTransactionDetails = toggleTransactionDetails; 52 | window.editTransaction = editTransaction; 53 | window.confirmDeleteTransaction = confirmDeleteTransaction; 54 | window.toggleBudgetForm = toggleBudgetForm; 55 | } 56 | 57 | /** 58 | * Add refresh button to budget header 59 | */ 60 | function addRefreshButton() { 61 | const refreshButton = document.createElement('button'); 62 | refreshButton.className = 'btn btn-sm btn-outline-secondary ms-2 refresh-budget-button'; 63 | refreshButton.innerHTML = ''; 64 | refreshButton.title = 'Refresh budget data'; 65 | refreshButton.addEventListener('click', updateBudgetProgressBars); 66 | 67 | // Add to current month budget header 68 | const budgetHeader = document.querySelector('.card-header:has(#current-month)'); 69 | if (budgetHeader) { 70 | const headerDiv = budgetHeader.querySelector('div'); 71 | if (headerDiv) { 72 | headerDiv.appendChild(refreshButton); 73 | } 74 | } 75 | } 76 | 77 | // Initialize the page when DOM is ready 78 | document.addEventListener('DOMContentLoaded', initializeBudgetPage); 79 | 80 | // Export main initialization for direct use 81 | export { initializeBudgetPage }; -------------------------------------------------------------------------------- /static/js/common/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common utility functions used across the application 3 | */ 4 | 5 | /** 6 | * Format a currency value with the user's locale 7 | * @param {number} amount - The amount to format 8 | * @param {string} currency - The currency code (default: 'USD') 9 | * @returns {string} Formatted currency string 10 | */ 11 | function formatCurrency(amount, currency = 'USD') { 12 | return new Intl.NumberFormat('en-US', { 13 | style: 'currency', 14 | currency: currency 15 | }).format(amount); 16 | } 17 | 18 | /** 19 | * Format a date string to a readable format 20 | * @param {string} dateString - ISO date string 21 | * @returns {string} Formatted date string 22 | */ 23 | function formatDate(dateString) { 24 | const date = new Date(dateString); 25 | return date.toLocaleDateString(); 26 | } 27 | 28 | /** 29 | * Show a toast notification 30 | * @param {string} message - Message to display 31 | * @param {string} type - Notification type: 'info', 'success', 'error', 'warning' 32 | * @param {number} duration - How long to show the toast (ms) 33 | */ 34 | function showToast(message, type = 'info', duration = 5000) { 35 | // Create toast container if it doesn't exist 36 | let toastContainer = document.getElementById('toast-container'); 37 | if (!toastContainer) { 38 | toastContainer = document.createElement('div'); 39 | toastContainer.id = 'toast-container'; 40 | toastContainer.className = 'position-fixed bottom-0 end-0 p-3'; 41 | toastContainer.style.zIndex = '5'; 42 | document.body.appendChild(toastContainer); 43 | } 44 | 45 | // Create unique ID for this toast 46 | const toastId = 'toast-' + Date.now(); 47 | 48 | // Determine toast color based on type 49 | let bgClass = 'bg-primary'; 50 | if (type === 'success') bgClass = 'bg-success'; 51 | if (type === 'error') bgClass = 'bg-danger'; 52 | if (type === 'warning') bgClass = 'bg-warning text-dark'; 53 | 54 | // Create toast HTML 55 | const toastHtml = ` 56 | 66 | `; 67 | 68 | // Add toast to container 69 | toastContainer.innerHTML += toastHtml; 70 | 71 | // Initialize and show the toast 72 | const toastElement = document.getElementById(toastId); 73 | const toast = new bootstrap.Toast(toastElement, { delay: duration }); 74 | toast.show(); 75 | 76 | // Remove toast after it's hidden 77 | toastElement.addEventListener('hidden.bs.toast', function() { 78 | toastElement.remove(); 79 | }); 80 | } 81 | 82 | /** 83 | * Add hover effects to cards 84 | * @param {string} selector - CSS selector for cards 85 | */ 86 | function addCardHoverEffects(selector = '.card') { 87 | const cards = document.querySelectorAll(selector); 88 | 89 | cards.forEach(card => { 90 | card.addEventListener('mouseenter', function() { 91 | this.style.transform = 'translateY(-3px)'; 92 | this.style.boxShadow = '0 6px 12px rgba(0, 0, 0, 0.2)'; 93 | this.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease'; 94 | }); 95 | 96 | card.addEventListener('mouseleave', function() { 97 | this.style.transform = 'translateY(0)'; 98 | this.style.boxShadow = 'none'; 99 | }); 100 | }); 101 | } 102 | 103 | // Export utilities for use in other modules 104 | export { 105 | formatCurrency, 106 | formatDate, 107 | showToast, 108 | addCardHoverEffects 109 | }; -------------------------------------------------------------------------------- /static/js/currency_helper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Global Currency Helper 3 | * This script ensures the base currency symbol is consistently 4 | * available across all JavaScript files. 5 | * 6 | * Place this in a new file called currency_helper.js in your static/js directory 7 | */ 8 | 9 | // Initialize global currency symbol 10 | window.initCurrencySymbol = function(symbol) { 11 | // Set the global variable 12 | window.baseCurrencySymbol = symbol || '$'; 13 | 14 | // Also expose it through a consistent API 15 | window.CurrencyHelper = { 16 | getSymbol: function() { 17 | return window.baseCurrencySymbol; 18 | }, 19 | formatAmount: function(amount) { 20 | return window.baseCurrencySymbol + parseFloat(amount).toFixed(2); 21 | } 22 | }; 23 | 24 | console.log('Currency symbol initialized:', window.baseCurrencySymbol); 25 | }; 26 | 27 | // If there's a currency symbol in a data attribute, use it 28 | document.addEventListener('DOMContentLoaded', function() { 29 | // Look for currency data in various places 30 | const currencyElement = document.getElementById('currency-data'); 31 | if (currencyElement && currencyElement.dataset.symbol) { 32 | window.initCurrencySymbol(currencyElement.dataset.symbol); 33 | } else if (typeof baseCurrencySymbol !== 'undefined') { 34 | // If it was already set in a script tag, use that 35 | window.initCurrencySymbol(baseCurrencySymbol); 36 | } else { 37 | // Default fallback 38 | window.initCurrencySymbol('$'); 39 | } 40 | 41 | // Dispatch an event that other scripts can listen for 42 | document.dispatchEvent(new CustomEvent('currencySymbolReady')); 43 | }); -------------------------------------------------------------------------------- /static/js/dashboard/docker_chart_fix.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Docker Chart Fix - Ensures chart data is properly loaded in Docker environment 3 | * 4 | * This script should be included before any other scripts in your dashboard.html 5 | * to ensure data is correctly initialized for Chart.js 6 | */ 7 | 8 | // Immediately run when script loads 9 | (function() { 10 | console.log("Docker chart fix initializing..."); 11 | 12 | // Create a custom event that will trigger when data is ready 13 | const chartDataReadyEvent = new CustomEvent('chartDataReady'); 14 | 15 | // Function to extract and initialize chart data from the page 16 | function initializeChartData() { 17 | console.log("Initializing chart data..."); 18 | 19 | try { 20 | // Check if data is already initialized 21 | if (window.chartDataInitialized) { 22 | console.log("Chart data already initialized, skipping"); 23 | return; 24 | } 25 | 26 | // Set default currency symbol 27 | if (!window.baseCurrencySymbol) { 28 | window.baseCurrencySymbol = '$'; 29 | console.log("Set default base currency symbol"); 30 | } 31 | 32 | // Extract category data if not already defined 33 | if (!window.categoryData) { 34 | console.log("Extracting category data from the page"); 35 | window.categoryData = []; 36 | 37 | // Look for category data in script tags 38 | document.querySelectorAll('script').forEach(script => { 39 | if (script.textContent.includes('categoryData')) { 40 | try { 41 | // Try to extract the array from the script content 42 | const match = script.textContent.match(/window\.categoryData\s*=\s*(\[[\s\S]*?\]);/); 43 | if (match && match[1]) { 44 | // Safely evaluate the array string 45 | const categoryDataStr = match[1].replace(/\\"/g, '"'); 46 | window.categoryData = JSON.parse(categoryDataStr); 47 | console.log(`Extracted ${window.categoryData.length} category items`); 48 | } 49 | } catch (e) { 50 | console.error("Error extracting category data:", e); 51 | } 52 | } 53 | }); 54 | } 55 | 56 | // Extract asset/debt trend data if not already defined 57 | if (!window.assetTrendsMonths) { 58 | console.log("Extracting asset/debt trend data from the page"); 59 | 60 | // Look for trend data in script tags 61 | document.querySelectorAll('script').forEach(script => { 62 | if (script.textContent.includes('assetTrendsMonths')) { 63 | try { 64 | // Try to extract the arrays 65 | let match = script.textContent.match(/window\.assetTrendsMonths\s*=\s*([^;]*);/); 66 | if (match && match[1]) { 67 | window.assetTrendsMonths = JSON.parse(match[1]); 68 | } 69 | 70 | match = script.textContent.match(/window\.assetTrends\s*=\s*([^;]*);/); 71 | if (match && match[1]) { 72 | window.assetTrends = JSON.parse(match[1]); 73 | } 74 | 75 | match = script.textContent.match(/window\.debtTrends\s*=\s*([^;]*);/); 76 | if (match && match[1]) { 77 | window.debtTrends = JSON.parse(match[1]); 78 | } 79 | 80 | console.log("Extracted trend data"); 81 | } catch (e) { 82 | console.error("Error extracting trend data:", e); 83 | } 84 | } 85 | }); 86 | } 87 | 88 | // Mark as initialized 89 | window.chartDataInitialized = true; 90 | 91 | // Dispatch event to notify charts they can initialize 92 | document.dispatchEvent(chartDataReadyEvent); 93 | console.log("Chart data initialized successfully"); 94 | } catch (error) { 95 | console.error("Error in chart data initialization:", error); 96 | } 97 | } 98 | 99 | // Initialize on DOMContentLoaded 100 | document.addEventListener('DOMContentLoaded', function() { 101 | console.log("DOM loaded, initializing chart data"); 102 | 103 | // Call immediately for the initial load 104 | initializeChartData(); 105 | 106 | // Also, set a fallback timer in case some scripts load slowly 107 | setTimeout(function() { 108 | if (!window.chartDataInitialized) { 109 | console.log("Fallback initialization of chart data"); 110 | initializeChartData(); 111 | } 112 | }, 1000); 113 | }); 114 | 115 | // Also run immediately in case the DOM is already loaded 116 | if (document.readyState === 'complete' || document.readyState === 'interactive') { 117 | console.log("Document already interactive/complete, initializing immediately"); 118 | initializeChartData(); 119 | } 120 | 121 | // Create a global check function 122 | window.checkChartData = function() { 123 | console.log("Chart data check:", { 124 | initialized: window.chartDataInitialized || false, 125 | baseCurrencySymbol: window.baseCurrencySymbol, 126 | categoryData: window.categoryData ? `${window.categoryData.length} items` : 'Not found', 127 | assetTrendsMonths: window.assetTrendsMonths ? `${window.assetTrendsMonths.length} items` : 'Not found', 128 | assetTrends: window.assetTrends ? `${window.assetTrends.length} items` : 'Not found', 129 | debtTrends: window.debtTrends ? `${window.debtTrends.length} items` : 'Not found' 130 | }); 131 | }; 132 | })(); -------------------------------------------------------------------------------- /static/js/delete/t: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harung1993/dollardollar/15260bec782f026bb714fa271c70e848da624b11/static/js/delete/t -------------------------------------------------------------------------------- /static/js/split_category_fix.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Enhanced Toggle Function for Category Splits 3 | * This code fixes the issue where toggling "Split this transaction into multiple categories" 4 | * doesn't show the split UI for transactions without existing splits 5 | */ 6 | 7 | // Override the event listener for the enable_category_split checkbox 8 | function fixCategorySplitToggle() { 9 | console.log("Applying fix for category split toggle..."); 10 | 11 | const enableCategorySplitCheck = document.getElementById('enable_category_split'); 12 | const categorySplitsContainer = document.getElementById('category_splits_container'); 13 | 14 | if (!enableCategorySplitCheck || !categorySplitsContainer) { 15 | console.warn("Category split elements not found, cannot apply fix"); 16 | return; 17 | } 18 | 19 | // Remove any existing event listeners by cloning and replacing 20 | const newCheckbox = enableCategorySplitCheck.cloneNode(true); 21 | enableCategorySplitCheck.parentNode.replaceChild(newCheckbox, enableCategorySplitCheck); 22 | 23 | // Add our enhanced event listener 24 | newCheckbox.addEventListener('change', function() { 25 | console.log("Category split toggle changed:", this.checked); 26 | const isChecked = this.checked; 27 | 28 | // Show/hide the splits container 29 | categorySplitsContainer.style.display = isChecked ? 'block' : 'none'; 30 | 31 | // Toggle transition class for smooth appearance 32 | if (isChecked) { 33 | categorySplitsContainer.classList.remove('hidden'); 34 | categorySplitsContainer.classList.add('visible'); 35 | } else { 36 | categorySplitsContainer.classList.remove('visible'); 37 | categorySplitsContainer.classList.add('hidden'); 38 | } 39 | 40 | // Disable/enable main category field 41 | const categorySelect = document.getElementById('edit_category_id'); 42 | if (categorySelect) { 43 | categorySelect.disabled = isChecked; 44 | categorySelect.parentElement.classList.toggle('opacity-50', isChecked); 45 | } 46 | 47 | if (isChecked) { 48 | // Clear existing splits first 49 | const categorySplitsList = document.getElementById('category_splits_list'); 50 | if (categorySplitsList) { 51 | categorySplitsList.innerHTML = ''; 52 | 53 | // Add a new split with the full transaction amount 54 | const amountInput = document.getElementById('edit_amount'); 55 | const totalAmount = parseFloat(amountInput?.value) || 0; 56 | 57 | console.log("Adding initial split with amount:", totalAmount); 58 | 59 | // Call the existing addCategorySplit function 60 | if (typeof addCategorySplit === 'function') { 61 | addCategorySplit(totalAmount); 62 | updateSplitTotals(); 63 | } else { 64 | // Fallback implementation if the function doesn't exist 65 | createFallbackSplit(categorySplitsList, totalAmount); 66 | } 67 | } 68 | } else { 69 | // Clear split data 70 | const categorySplitsData = document.getElementById('category_splits_data'); 71 | if (categorySplitsData) { 72 | categorySplitsData.value = ''; 73 | } 74 | } 75 | }); 76 | 77 | // Also fix the "Add Split" button to ensure it works properly 78 | const addSplitBtn = document.getElementById('add_split_btn'); 79 | if (addSplitBtn) { 80 | // Remove existing listeners 81 | const newAddBtn = addSplitBtn.cloneNode(true); 82 | addSplitBtn.parentNode.replaceChild(newAddBtn, addSplitBtn); 83 | 84 | // Add our enhanced listener 85 | newAddBtn.addEventListener('click', function() { 86 | console.log("Add split button clicked"); 87 | 88 | // Call the existing function if available 89 | if (typeof addCategorySplit === 'function') { 90 | addCategorySplit(0); // Add with zero amount 91 | updateSplitTotals(); 92 | } else { 93 | // Fallback implementation 94 | const categorySplitsList = document.getElementById('category_splits_list'); 95 | if (categorySplitsList) { 96 | createFallbackSplit(categorySplitsList, 0); 97 | } 98 | } 99 | }); 100 | } 101 | 102 | console.log("Category split toggle fix applied"); 103 | } 104 | 105 | // Fallback implementation for adding a split (in case the original function isn't available) 106 | function createFallbackSplit(container, amount) { 107 | console.log("Using fallback split creation with amount:", amount); 108 | 109 | const splitId = Date.now(); // Generate unique ID 110 | 111 | const splitRow = document.createElement('div'); 112 | splitRow.className = 'row mb-3 split-row'; 113 | splitRow.dataset.splitId = splitId; 114 | 115 | // Create category dropdown and amount input 116 | splitRow.innerHTML = ` 117 |
118 | 122 |
123 |
124 |
125 | ${window.baseCurrencySymbol || '$'} 126 | 128 |
129 |
130 |
131 | 134 |
135 | `; 136 | 137 | container.appendChild(splitRow); 138 | 139 | // Add event handlers 140 | splitRow.querySelector('.split-amount').addEventListener('input', function() { 141 | if (typeof updateSplitTotals === 'function') { 142 | updateSplitTotals(); 143 | } 144 | }); 145 | 146 | splitRow.querySelector('.split-category').addEventListener('change', function() { 147 | if (typeof updateSplitTotals === 'function') { 148 | updateSplitTotals(); 149 | } 150 | }); 151 | 152 | splitRow.querySelector('.remove-split').addEventListener('click', function() { 153 | splitRow.remove(); 154 | if (typeof updateSplitTotals === 'function') { 155 | updateSplitTotals(); 156 | } 157 | }); 158 | } 159 | 160 | // Fallback implementation of updateSplitTotals (in case the original isn't available) 161 | function fallbackUpdateSplitTotals() { 162 | const transactionTotal = parseFloat(document.getElementById('edit_amount')?.value) || 0; 163 | let splitTotal = 0; 164 | 165 | // Calculate sum of all splits 166 | document.querySelectorAll('.split-row').forEach(row => { 167 | const amountInput = row.querySelector('.split-amount'); 168 | splitTotal += parseFloat(amountInput?.value) || 0; 169 | }); 170 | 171 | // Update UI 172 | const splitTotalEl = document.getElementById('split_total'); 173 | const transactionTotalEl = document.getElementById('transaction_total'); 174 | 175 | if (splitTotalEl) splitTotalEl.textContent = splitTotal.toFixed(2); 176 | if (transactionTotalEl) transactionTotalEl.textContent = transactionTotal.toFixed(2); 177 | 178 | // Update status 179 | const statusEl = document.getElementById('split_status'); 180 | if (statusEl) { 181 | if (Math.abs(splitTotal - transactionTotal) < 0.01) { 182 | statusEl.textContent = 'Balanced'; 183 | statusEl.className = 'badge bg-success'; 184 | } else if (splitTotal < transactionTotal) { 185 | statusEl.textContent = 'Underfunded'; 186 | statusEl.className = 'badge bg-warning'; 187 | } else { 188 | statusEl.textContent = 'Overfunded'; 189 | statusEl.className = 'badge bg-danger'; 190 | } 191 | } 192 | 193 | // Update hidden input with split data 194 | updateSplitDataField(); 195 | } 196 | 197 | // Helper function to update the hidden input with split data 198 | function updateSplitDataField() { 199 | const splitRows = document.querySelectorAll('.split-row'); 200 | const splitData = []; 201 | 202 | splitRows.forEach(row => { 203 | const categorySelect = row.querySelector('.split-category'); 204 | const amountInput = row.querySelector('.split-amount'); 205 | 206 | if (categorySelect && amountInput) { 207 | const categoryId = categorySelect.value; 208 | const amount = parseFloat(amountInput.value) || 0; 209 | 210 | if (categoryId && amount > 0) { 211 | splitData.push({ 212 | category_id: categoryId, 213 | amount: amount 214 | }); 215 | } 216 | } 217 | }); 218 | 219 | const categorySplitsDataEl = document.getElementById('category_splits_data'); 220 | if (categorySplitsDataEl) { 221 | categorySplitsDataEl.value = JSON.stringify(splitData); 222 | } 223 | } 224 | 225 | // Initialize the fix when the edit form appears in the DOM 226 | function initializeSplitFix() { 227 | // Check if we're on a page with the edit form 228 | if (document.getElementById('editTransactionForm')) { 229 | console.log("Edit form found, applying category split fix"); 230 | fixCategorySplitToggle(); 231 | } 232 | } 233 | 234 | // Add listener to initialize when the edit panel is opened 235 | document.addEventListener('click', function(e) { 236 | if (e.target.closest('.edit-expense-btn')) { 237 | console.log("Edit button clicked, scheduling split fix"); 238 | // Wait for form to load 239 | setTimeout(initializeSplitFix, 500); 240 | } 241 | }); 242 | 243 | // Initialize right away if the form is already in the DOM 244 | document.addEventListener('DOMContentLoaded', function() { 245 | if (document.getElementById('editTransactionForm')) { 246 | console.log("Edit form already in DOM on page load, applying category split fix"); 247 | fixCategorySplitToggle(); 248 | } 249 | }); 250 | 251 | // Additional fix for the global updateSplitTotals function 252 | if (typeof window.updateSplitTotals !== 'function') { 253 | window.updateSplitTotals = fallbackUpdateSplitTotals; 254 | } -------------------------------------------------------------------------------- /templates/add.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |

Add New Expense

6 |
7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 33 |
34 | 35 |
36 | 37 | 42 |
43 | 44 | 49 | 50 | 51 |
52 | 53 | 76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |

Admin Dashboard

7 | 8 | 9 |
10 |
11 |
Add New User
12 |
13 |
14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 |
37 |
38 |
39 | 40 | 41 |
42 |
43 |
Manage Users
44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% for user in users %} 58 | 59 | 60 | 61 | 78 | 91 | 92 | 93 | {% if user.id != current_user.id %} 94 | 95 | 132 | 133 | {% endif %} 134 | {% endfor %} 135 | 136 |
NameEmailAdmin StatusActions
{{ user.name }}{{ user.id }} 62 | {% if user.id != current_user.id %} 63 |
64 | {% if user.is_admin %} 65 | 68 | {% else %} 69 | 72 | {% endif %} 73 |
74 | {% else %} 75 | Admin (You) 76 | {% endif %} 77 |
79 | {% if user.id != current_user.id %} 80 |
81 | 85 |
86 | 87 |
88 |
89 | {% endif %} 90 |
137 |
138 |
139 |
140 |
141 | {% endblock %} 142 | 143 | {% block scripts %} 144 | 175 | {% endblock %} -------------------------------------------------------------------------------- /templates/cache_management.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

API Cache Management

7 | 8 | Back to Investments 9 | 10 |
11 | 12 | 13 |
14 |
15 |
FMP API Cache Statistics
16 |
17 |
18 |
19 |
20 |
21 |
22 |

{{ stats.hits }}

23 |

Cache Hits

24 |
25 |
26 |
27 |
28 |
29 |

{{ stats.misses }}

30 |

Cache Misses

31 |
32 |
33 |
34 |
35 |
36 |

{{ stats.api_calls }}

37 |

API Calls Made

38 |
39 |
40 |
41 |
42 |
43 |

{{ stats.hit_rate }}

44 |

Cache Hit Rate

45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |

{{ stats.api_calls_saved }}

54 |

API Calls Saved

55 |
56 |
57 |
58 |
59 |
60 |

{{ cache_expiry }}

61 |

Cache Expiry Time

62 |
63 |
64 |
65 | 66 |
67 | 68 | The FMP API has a limit of 250 calls per day. Using the cache has saved you {{ stats.api_calls_saved }} API calls so far. 69 |
70 |
71 |
72 | 73 | 74 |
75 |
76 |
Cache Management
77 |
78 |
79 |
80 |
81 |
82 |
83 |
Clear Expired Cache
84 |

Remove cache entries that have passed their expiry time.

85 |
86 | 89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
Clear All Cache
97 |

Remove all cached data. Use this if you need fresh data from the API.

98 |
99 | 102 |
103 |
104 |
105 |
106 |
107 | 108 |
109 |
Cached Files
110 | {% if cache_files %} 111 |
112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | {% for file in cache_files %} 123 | 124 | 125 | 126 | 127 | 134 | 135 | {% endfor %} 136 | 137 |
Cache KeySizeLast ModifiedStatus
{{ file.key }}{{ file.size }}{{ file.modified }} 128 | {% if file.expired %} 129 | Expired 130 | {% else %} 131 | Valid 132 | {% endif %} 133 |
138 |
139 | {% else %} 140 |
141 | 142 | No cache files found. 143 |
144 | {% endif %} 145 |
146 |
147 |
148 |
149 | {% endblock %} -------------------------------------------------------------------------------- /templates/demo/demo_concurrent.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 |
4 |
5 |
6 |
7 |
8 |

Oops! We're a bit crowded at the moment.

9 |

We apologize, but it seems like we've reached the maximum number of concurrent users for our demo environment. We're working hard to accommodate everyone, but in the meantime, please try again later.

10 | 11 |
12 |

While You Wait...

13 | 14 |
15 |
16 |
17 |
18 |

19 | 20 | Explore Our GitHub 21 |

22 |

Check out our open-source project and see what's under the hood!

23 | 24 | View Repository 25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |

34 | 35 | Chat with Our Community 36 |

37 |

Join our Discord server and engage with fellow financial enthusiasts!

38 | 39 | Join Discord 40 | 41 |
42 |
43 |
44 |
45 |
46 | 47 |
48 |

Dollar Dollar Bill Y'all Features

49 |
50 |
51 |
52 |
53 | 54 |

Streamlined Expense Tracking

55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 |

Collaborative Group Expenses

63 |
64 |
65 |
66 |
67 |
68 |
69 | 70 |

Powerful Financial Insights

71 |
72 |
73 |
74 |
75 |
76 | 77 | Try Again 78 |
79 | 82 |
83 |
84 |
85 |
86 | {% endblock %} 87 |
-------------------------------------------------------------------------------- /templates/demo/demo_expired.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |

Demo Session Expired

10 |
11 |
12 |
13 | 14 |

Your demo session has ended

15 |

Thank you for trying DollarDollar Bill Y'all!

16 |
17 | 18 |
19 |
Did you enjoy the demo?
20 |

Here are your options to continue using DollarDollar Bill Y'all:

21 |
    22 |
  • Start a new demo session to explore more features
  • 23 |
  • Install your own instance using the instructions in our documentation
  • 24 |
  • Contact us to learn about extended trials or hosting options
  • 25 |
26 |
27 | 28 | 36 |
37 |
38 |
39 |
40 |
41 | {% endblock %} -------------------------------------------------------------------------------- /templates/demo/demo_thanks.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 |
4 |
5 |
6 |
7 |
8 |

Thank You for Trying Dollar Dollar Bill Y'all!

9 |

We hope you enjoyed exploring our expense tracking application and found it helpful for managing your finances.

10 | 11 |
12 |

Next Steps

13 | 14 |
15 |
16 |
17 |
18 |

19 | 20 | Check Out Our GitHub 21 |

22 |

We're an open-source project and would love your support!

23 | 24 | View Repository 25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |

34 | 35 | Join Our Community 36 |

37 |

Connect with us and other users on Discord!

38 | 39 | Join Discord 40 | 41 |
42 |
43 |
44 |
45 |
46 | 47 |
48 |

App Highlights

49 |
50 |
51 |
52 |
53 | 54 |

Expense Tracking

55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 |

Group Expenses

63 |
64 |
65 |
66 |
67 |
68 |
69 | 70 |

Financial Insights

71 |
72 |
73 |
74 |
75 |
76 |
77 | 80 |
81 |
82 |
83 |
84 | {% endblock %} 85 |
-------------------------------------------------------------------------------- /templates/email/monthly_report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 26 | 27 |
28 |
29 |

Your Monthly Finance Report

30 |

{{ month_name }} {{ year }}

31 |
32 |
33 |
34 |

Monthly Summary

35 |

Total Expenses: {{ currency_symbol }}{{ "%.2f"|format(total_spent) }}

36 |

Number of Transactions: {{ expense_count }}

37 | {% if prev_total > 0 %} 38 |

Compared to Last Month: 39 | {% if spending_trend < 0 %} 40 | ▼ {{ "%.1f"|format(spending_trend|abs) }}% 41 | {% else %} 42 | ▲ {{ "%.1f"|format(spending_trend) }}% 43 | {% endif %} 44 |

45 | {% endif %} 46 |
47 | 48 |
49 |

Budget Status

50 | {% if budget_status %} 51 | {% for budget in budget_status %} 52 |
53 |
{{ budget.name }}: {{ currency_symbol }}{{ "%.2f"|format(budget.spent) }} / {{ currency_symbol }}{{ "%.2f"|format(budget.amount) }}
54 |
55 |
56 |
57 |
58 | {% endfor %} 59 | {% else %} 60 |

You don't have any active budgets set up. Create a budget to track your spending against your financial goals.

61 | {% endif %} 62 |
63 | 64 |
65 |

Top Categories

66 |
    67 | {% for category in category_data %} 68 |
  • {{ category.name }}: {{ currency_symbol }}{{ "%.2f"|format(category.amount) }} ({{ "%.1f"|format(category.percentage) }}%)
  • 69 | {% endfor %} 70 |
71 |
72 | 73 |
74 |

Balance Summary

75 |
76 |

You are owed: {{ currency_symbol }}{{ "%.2f"|format(sum(item.amount for item in you_are_owed)) }}

77 |

You owe others: {{ currency_symbol }}{{ "%.2f"|format(sum(item.amount for item in you_owe)) }}

78 |

Net balance: 79 | 80 | {{ currency_symbol }}{{ "%.2f"|format(net_balance) }} 81 | 82 |

83 |
84 |
85 | 86 |
87 |

Top Expenses This Month

88 | {% if top_expenses %} 89 | {% for expense in top_expenses %} 90 |
91 | {{ expense.date.strftime('%Y-%m-%d') }} - {{ expense.description }} 92 | {{ currency_symbol }}{{ "%.2f"|format(expense.amount) }} 93 |
94 | {% endfor %} 95 | {% else %} 96 |

No expenses recorded this month.

97 | {% endif %} 98 |
99 | 100 | View Detailed Report 101 |
102 | 106 |
107 | 108 | -------------------------------------------------------------------------------- /templates/email/monthly_report.txt: -------------------------------------------------------------------------------- 1 | 2 | Monthly Expense Report - {{ month_name }} {{ year }} 3 | 4 | Hi {{ user.name }}, 5 | 6 | Here's your monthly expense summary for {{ month_name }} {{ year }}. 7 | 8 | SUMMARY 9 | ------------------------------- 10 | Total Spent: {{ currency_symbol }}{{ "%.2f"|format(total_spent) }} 11 | Number of Transactions: {{ expense_count }} 12 | {% if prev_total > 0 %} 13 | Compared to Last Month: {% if spending_trend < 0 %}▼ {{ "%.1f"|format(spending_trend|abs) }}% decrease{% else %}▲ {{ "%.1f"|format(spending_trend) }}% increase{% endif %} 14 | {% endif %} 15 | 16 | BUDGET STATUS 17 | ------------------------------- 18 | {% for budget in budget_status %} 19 | {{ budget.name }}: {{ currency_symbol }}{{ "%.2f"|format(budget.spent) }} / {{ currency_symbol }}{{ "%.2f"|format(budget.amount) }} ({{ "%.1f"|format(budget.percentage) }}%) 20 | {% endfor %} 21 | 22 | TOP CATEGORIES 23 | ------------------------------- 24 | {% for category in category_data %} 25 | {{ category.name }}: {{ currency_symbol }}{{ "%.2f"|format(category.amount) }} ({{ "%.1f"|format(category.percentage) }}%) 26 | {% endfor %} 27 | 28 | BALANCE SUMMARY 29 | ------------------------------- 30 | You are owed: {{ currency_symbol }}{{ "%.2f"|format(sum(item.amount for item in you_are_owed)) }} 31 | You owe others: {{ currency_symbol }}{{ "%.2f"|format(sum(item.amount for item in you_owe)) }} 32 | Net balance: {{ currency_symbol }}{{ "%.2f"|format(net_balance) }} 33 | 34 | TOP EXPENSES 35 | ------------------------------- 36 | {% for expense in top_expenses %} 37 | {{ expense.date.strftime('%Y-%m-%d') }} - {{ expense.description }}: {{ currency_symbol }}{{ "%.2f"|format(expense.amount) }} 38 | {% endfor %} 39 | 40 | To see more detailed statistics, visit: {{ url_for('stats', _external=True) }} 41 | 42 | You're receiving this email because you have monthly reports enabled. 43 | To adjust your notification preferences, visit your profile settings: {{ url_for('profile', _external=True) }}# 29a41de6a866d56c36aba5159f45257c 44 | -------------------------------------------------------------------------------- /templates/groups.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

My Groups

10 | 13 |
14 |
15 |
16 | 17 | 18 | 21 | 22 | 23 |
24 | {% for group in groups %} 25 |
26 |
27 |
28 |
{{ group.name }}
29 |
30 |
31 | {% if group.description %} 32 |

{{ group.description }}

33 | {% endif %} 34 |

35 | Created by: {{ group.creator.name }}
36 | Members: {{ group.members|length }} 37 | 38 | {% if group.default_split_method and group.default_split_method != 'equal' %} 39 |
Split Method: {{ group.default_split_method|capitalize }} 40 | {% endif %} 41 |

42 |
43 | 48 |
49 |
50 | {% else %} 51 |
52 |
53 | You haven't joined any groups yet. Create one to get started! 54 |
55 |
56 | {% endfor %} 57 |
58 |
59 | 60 | 61 | 83 | {% endblock %} 84 | 85 | {% block scripts %} 86 | 106 | {% endblock %} -------------------------------------------------------------------------------- /templates/import_results.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

Import Results

7 | 8 | Back to Advanced 9 | 10 |
11 | 12 |
13 | 14 | Successfully imported {{ count }} transactions. Skipped {{ duplicate_count }} duplicates. 15 |
16 | 17 | {% if count > 0 %} 18 |
19 |
20 |
Imported Transactions
21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% for expense in expenses %} 36 | 37 | 38 | 39 | 40 | 49 | 50 | 51 | {% endfor %} 52 | 53 |
DateDescriptionAmountCategoryCard Used
{{ expense.date.strftime('%Y-%m-%d') }}{{ expense.description }}{{ base_currency.symbol }}{{ "%.2f"|format(expense.amount) }} 41 | {% if expense.category %} 42 | 43 | {{ expense.category.name }} 44 | 45 | {% else %} 46 | Uncategorized 47 | {% endif %} 48 | {{ expense.card_used }}
54 |
55 |
56 |
57 | {% endif %} 58 | 59 |
60 | 61 | View All Transactions 62 | 63 | 64 | Import Another File 65 | 66 |
67 |
68 | {% endblock %} -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |

Monthly Summaries

6 | {% for month, data in monthly_totals.items() %} 7 |
8 |
9 |

{{ month }}

10 |
11 |
12 |
13 |
14 |
Total: ${{ "%.2f"|format(data.total) }}
15 |

Person 1 owes: ${{ "%.2f"|format(data.person1) }}

16 |

Person 2 owes: ${{ "%.2f"|format(data.person2) }}

17 |
18 |
19 |
Expenses by Card:
20 |
    21 | {% for card, amount in data.by_card.items() %} 22 |
  • {{ card }}: ${{ "%.2f"|format(amount) }}
  • 23 | {% endfor %} 24 |
25 |
26 |
27 |
28 |
29 | {% endfor %} 30 | 31 |

Recent Expenses

32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {% for expense in expenses %} 48 | {% set splits = expense.calculate_splits() %} 49 | 50 | 51 | 52 | 53 | 54 | 60 | 61 | 62 | 63 | 64 | {% endfor %} 65 | 66 |
DateDescriptionAmountCard UsedSplit MethodPerson 1 PaysPerson 2 PaysPaid By
{{ expense.date.strftime('%Y-%m-%d') }}{{ expense.description }}${{ "%.2f"|format(expense.amount) }}{{ expense.card_used }} 55 | {{ expense.split_method }} 56 | {% if expense.split_method != 'half' %} 57 | ({{ "%.2f"|format(expense.split_value) }}{% if expense.split_method == 'percentage' %}%{% endif %}) 58 | {% endif %} 59 | ${{ "%.2f"|format(splits.person1) }}${{ "%.2f"|format(splits.person2) }}{{ expense.paid_by }}
67 |
68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /templates/investments/partials/investment_details.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
Investment Summary
7 |
8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
Shares Owned:{{ "%.2f"|format(investment.shares) }}
Average Cost:${{ "%.2f"|format(investment.purchase_price) }}
Current Price:${{ "%.2f"|format(investment.current_price) }}
Total Cost:${{ "%.2f"|format(investment.cost_basis) }}
Current Value:${{ "%.2f"|format(investment.current_value) }}
Gain/Loss: 33 | ${{ "%.2f"|format(investment.gain_loss) }} ({{ "%.2f"|format(investment.gain_loss_percentage) }}%) 34 |
Purchase Date:{{ investment.purchase_date.strftime('%Y-%m-%d') }}
Last Updated:{{ investment.last_update.strftime('%Y-%m-%d %H:%M') if investment.last_update else 'Never' }}
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
Market Data
53 |
54 |
55 | {% if stock_data %} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 70 | 71 | {% if stock_data.sector %} 72 | 73 | 74 | 75 | 76 | {% endif %} 77 | {% if stock_data.industry %} 78 | 79 | 80 | 81 | 82 | {% endif %} 83 | {% if stock_data.market_cap %} 84 | 85 | 86 | 87 | 88 | {% endif %} 89 | {% if stock_data.website %} 90 | 91 | 92 | 93 | 94 | {% endif %} 95 |
Company:{{ stock_data.name }}
Current Price:${{ "%.2f"|format(stock_data.price) }}
Daily Change: 68 | ${{ "%.2f"|format(stock_data.change) }} ({{ "%.2f"|format(stock_data.percent_change) }}%) 69 |
Sector:{{ stock_data.sector }}
Industry:{{ stock_data.industry }}
Market Cap:${{ '{:,.2f}'.format(stock_data.market_cap / 1000000000) }} B
Website:{{ stock_data.website }}
96 | {% else %} 97 |
98 | 99 | Could not fetch current market data. 100 |
101 | {% endif %} 102 |
103 |
104 |
105 |
106 | 107 | {% if investment.description %} 108 |
109 |
110 |
Notes
111 |
112 |
113 |

{{ investment.notes }}

114 |
115 |
116 | {% endif %} 117 | 118 |
119 |
120 |
Transaction History
121 |
122 |
123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | {% for transaction in transactions %} 136 | 137 | 138 | 151 | 152 | 153 | 154 | 155 | {% else %} 156 | 157 | 160 | 161 | {% endfor %} 162 | 163 |
DateTypeSharesPriceTotal Value
{{ transaction.date.strftime('%Y-%m-%d') }} 139 | {% if transaction.transaction_type == 'buy' %} 140 | Buy 141 | {% elif transaction.transaction_type == 'sell' %} 142 | Sell 143 | {% elif transaction.transaction_type == 'dividend' %} 144 | Dividend 145 | {% elif transaction.transaction_type == 'split' %} 146 | Split 147 | {% else %} 148 | {{ transaction.transaction_type|capitalize }} 149 | {% endif %} 150 | {{ "%.2f"|format(transaction.shares) }}${{ "%.2f"|format(transaction.price) }}${{ "%.2f"|format(transaction.transaction_value) }}
158 |

No transaction history found.

159 |
164 |
165 |
166 |
-------------------------------------------------------------------------------- /templates/investments/transactions.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

Investment Transactions

7 | 8 | Back to Dashboard 9 | 10 |
11 | 12 | 13 |
14 |
15 |
Transaction History
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% for transaction in transactions %} 34 | 35 | 36 | 37 | 41 | 54 | 55 | 56 | 57 | 66 | 67 | {% else %} 68 | 69 | 78 | 79 | {% endfor %} 80 | 81 |
DatePortfolioSymbolTypeSharesPriceValueNotes
{{ transaction.date.strftime('%Y-%m-%d') }}{{ transaction.portfolio_name }} 38 | {{ transaction.investment_symbol }} 39 |
{{ transaction.investment_name }}
40 |
42 | {% if transaction.transaction_type == 'buy' %} 43 | Buy 44 | {% elif transaction.transaction_type == 'sell' %} 45 | Sell 46 | {% elif transaction.transaction_type == 'dividend' %} 47 | Dividend 48 | {% elif transaction.transaction_type == 'split' %} 49 | Split 50 | {% else %} 51 | {{ transaction.transaction_type|capitalize }} 52 | {% endif %} 53 | {{ "%.2f"|format(transaction.shares) }}${{ "%.2f"|format(transaction.price) }}${{ "%.2f"|format(transaction.value) }} 58 | {% if transaction.notes %} 59 | 64 | {% endif %} 65 |
70 |
71 | 72 |
73 |

No investment transactions found. Add your first investment to get started.

74 | 75 | Manage Portfolios 76 | 77 |
82 |
83 |
84 |
85 |
86 | {% endblock %} 87 | 88 | {% block scripts %} 89 | 98 | {% endblock %} -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Login

10 |
11 |
12 | {% if oidc_enabled %} 13 | 18 | 19 | {% if not local_login_disabled %} 20 |
21 | OR 22 |
23 | {% endif %} 24 | {% endif %} 25 | 26 | {% if not local_login_disabled %} 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 | Forgot Password? 37 |
38 |
39 |
40 | 41 |
42 |
43 | {% else %} 44 |
45 | Local login has been disabled. Please use {{ config.get('OIDC_PROVIDER_NAME', 'SSO') }}. 46 |
47 | {% endif %} 48 | 49 |

50 | {% if not signups_disabled %} 51 | Don't have an account? Sign up 52 | {% else %} 53 | New registrations are currently disabled. 54 | {% endif %} 55 |

56 |
57 |
58 |
59 |
60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /templates/manage_ignored_patterns.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |

Ignored Recurring Patterns

9 | 10 | Back to Recurring Expenses 11 | 12 |
13 |
14 |
15 | 16 |
17 |
18 |
Manage Ignored Patterns
19 |

These patterns have been excluded from recurring transaction detection

20 |
21 |
22 | {% if ignored_patterns %} 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% for pattern in ignored_patterns %} 36 | 37 | 38 | 39 | 40 | 41 | 49 | 50 | {% endfor %} 51 | 52 |
DescriptionAmountFrequencyIgnored SinceActions
{{ pattern.description }}{{ base_currency.symbol }}{{ "%.2f"|format(pattern.amount) }}{{ pattern.frequency|capitalize }}{{ pattern.ignore_date.strftime('%Y-%m-%d') }} 42 |
44 | 47 |
48 |
53 |
54 | {% else %} 55 |
56 | 57 | You haven't ignored any recurring transaction patterns yet. 58 |
59 | {% endif %} 60 |
61 |
62 | 63 |
64 |
65 |
About Ignored Patterns
66 |
67 |
68 |

When you ignore a recurring transaction pattern, it will no longer appear in your detected recurring 69 | transactions list. This is useful for:

70 |
    71 |
  • Patterns that are incorrectly detected as recurring
  • 72 |
  • Transactions that happen regularly but you don't want to track as a recurring expense
  • 73 |
  • Historical patterns that are no longer relevant
  • 74 |
75 |

You can restore any ignored pattern from this page if you change your mind later.

76 |

To add a pattern to your ignore list, click the "Ignore" button on a detected recurring transaction.

77 |
78 |
79 |
80 | {% endblock %} -------------------------------------------------------------------------------- /templates/partials/recurring_transaction_form.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |
7 | 8 | 11 | 12 | 13 | 16 | 17 | 18 | 21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 |
37 |
38 | 39 | 47 |
48 |
49 | 50 | 58 |
59 |
60 | 61 |
62 | 63 |
64 | 65 | 75 | 76 | 77 | {% if current_user.accounts|length == 0 %} 78 | No accounts created yet. Create accounts to better track your finances. 79 | {% endif %} 80 | 81 |
82 | 83 | 84 | 93 |
94 | 95 |
96 |
97 | 98 | 99 |
100 |
101 | 102 | 103 | Leave blank for no end date 104 |
105 |
106 | 107 | 108 |
109 |
110 |
111 | 112 | 119 |
120 |
121 |
122 | 123 |
124 | 125 | 126 |
127 |
128 | 133 |
134 |
135 | 136 |
137 | 138 | 143 |
144 | 145 | 146 | 163 | 164 | 165 |
166 | 167 | 173 |
174 |
175 | 176 | 177 |
178 | 179 | 195 |
196 | 197 |
198 | 199 | 200 |
201 |
202 |
-------------------------------------------------------------------------------- /templates/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Reset Password

10 |
11 |
12 |

Enter your email address and we'll send you a link to reset your password.

13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 |
21 |
22 |

23 | Back to Login 24 |

25 |
26 |
27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /templates/reset_password_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Create New Password

10 |
11 |
12 |

Enter your new password below.

13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 |
28 |

29 | Back to Login 30 |

31 |
32 |
33 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /templates/settlements.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |

Settle Up

9 |
10 |
11 | 12 | 13 |
14 |
15 |
16 |
17 |
You Owe
18 |
19 |
20 | {% if you_owe %} 21 |
22 | {% for balance in you_owe %} 23 |
24 |
25 | {{ balance.name }} 26 | ({{ balance.email }}) 27 |
28 |
29 | {{ base_currency.symbol }}{{ "%.2f"|format(balance.amount) }} 30 | 33 |
34 |
35 | {% endfor %} 36 |
37 | {% else %} 38 |

You don't owe anyone money

39 | {% endif %} 40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 |
You Are Owed
48 |
49 |
50 | {% if you_are_owed %} 51 |
52 | {% for balance in you_are_owed %} 53 |
54 |
55 | {{ balance.name }} 56 | ({{ balance.email }}) 57 |
58 |
59 | {{ base_currency.symbol }}{{ "%.2f"|format(balance.amount) }} 60 | 63 |
64 |
65 | {% endfor %} 66 |
67 | {% else %} 68 |

Nobody owes you money

69 | {% endif %} 70 |
71 |
72 |
73 |
74 | 75 | 76 |
77 |
78 |
Record a Settlement
79 |
80 |
81 |
82 |
83 |
84 | 85 | 91 |
92 |
93 | 94 | 100 |
101 |
102 |
103 |
104 | 105 | 106 |
107 |
108 | 109 | 110 |
111 |
112 | 113 | 114 |
115 |
116 |
117 | 118 |
119 |
120 |
121 |
122 | 123 | 124 |
125 |
126 |
Recent Settlements
127 |
128 |
129 |
130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | {% if settlements %} 142 | {% for settlement in settlements %} 143 | 144 | 145 | 146 | 153 | 160 | 161 | 162 | {% endfor %} 163 | {% else %} 164 | 165 | 166 | 167 | {% endif %} 168 | 169 |
DateDescriptionFromToAmount
{{ settlement.date.strftime('%Y-%m-%d') }}{{ settlement.description }} 147 | {% if settlement.payer_id == current_user_id %} 148 | You 149 | {% else %} 150 | {{ settlement.payer.name }} 151 | {% endif %} 152 | 154 | {% if settlement.receiver_id == current_user_id %} 155 | You 156 | {% else %} 157 | {{ settlement.receiver.name }} 158 | {% endif %} 159 | {{ base_currency.symbol }}{{ "%.2f"|format(settlement.amount) }}
No settlements yet
170 |
171 |
172 |
173 |
174 | {% endblock %} 175 | 176 | {% block scripts %} 177 | 206 | {% endblock %} 207 | -------------------------------------------------------------------------------- /templates/setup_investment_api.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |
10 | 11 | Setup Financial Modeling Prep API Key 12 |
13 |
14 |
15 |
16 |
17 | 18 | About Financial Modeling Prep 19 |
20 |

To track investments, you need a Financial Modeling Prep API key. This allows us to fetch real-time stock data and keep your portfolio up to date.

21 |
22 |

Follow these steps to get your API key:

23 |
    24 |
  1. Visit Financial Modeling Prep
  2. 25 |
  3. Sign up for an account (they offer a free tier with limited API calls)
  4. 26 |
  5. Once registered, go to your dashboard to find your API key
  6. 27 |
  7. Copy the key and paste it below
  8. 28 |
29 |
30 | 31 |
32 |
33 | 34 | 35 |
Your API key will be securely stored and only used for fetching investment data.
36 |
37 | 38 |
39 | 40 | Back to Dashboard 41 | 42 | 45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {% endblock %} -------------------------------------------------------------------------------- /templates/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Create Account

10 |
11 |
12 | {% if oidc_enabled and local_login_disabled %} 13 |
14 | Direct account creation is disabled. Please login with SSO instead. 15 |
16 | {% else %} 17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 | {% endif %} 35 | 36 |

37 | Already have an account? Login 38 |

39 |
40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /templates/simplefin_accounts.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Connect SimpleFin Accounts

6 | 7 |
8 |
9 |
Review and Customize Accounts
10 |
11 |
12 |

The following accounts are available from your financial institutions. You can customize account names and types before importing:

13 |
14 | 15 | Note: Account names should be 150 characters or less. 16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% for account in accounts %} 31 | 32 | 38 | 39 | 46 | 56 | 57 | 58 | {% endfor %} 59 | 60 |
SelectInstitutionAccount NameTypeBalance
33 |
34 | 35 | 36 |
37 |
{{ account.institution }} 40 | 41 | 45 | 47 | 48 | 55 | {{ account.currency_code }} {{ "%.2f"|format(account.balance) }}
61 |
62 | 63 |
64 | 65 | By connecting these accounts, Dollar Dollar Bill Y'all will import transactions and account balances. Your bank credentials remain secure through SimpleFin and are never shared with us. 66 |
67 | 68 |
69 | 70 | Cancel 71 | 72 | 75 |
76 |
77 |
78 |
79 |
80 | {% endblock %} -------------------------------------------------------------------------------- /templates/tags.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Manage Tags

10 | 13 |
14 |
15 |
16 | 17 | 18 | 40 | 41 | 42 |
43 | {% for tag in tags %} 44 |
45 |
46 |
47 |
48 | {{ tag.name }} 49 |
50 |
51 | 54 |
55 |
56 |
57 |

58 | Created: {{ tag.created_at.strftime('%Y-%m-%d') }}
59 | Color: {{ tag.color }} 60 |

61 |

Used in {{ tag.expenses|length }} expenses

62 |
63 |
64 |
65 | {% else %} 66 |
67 |
68 | You haven't created any tags yet. Create one to categorize your expenses! 69 |
70 |
71 | {% endfor %} 72 |
73 |
74 | {% endblock %} 75 | 76 | {% block scripts %} 77 | 107 | {% endblock %} 108 | -------------------------------------------------------------------------------- /update_currencies.py: -------------------------------------------------------------------------------- 1 | r"""29a41de6a866d56c36aba5159f45257c""" 2 | #!/usr/bin/env python 3 | """ 4 | This script updates currency exchange rates in the application database. 5 | """ 6 | 7 | import os 8 | import sys 9 | from datetime import datetime 10 | import requests 11 | 12 | # Add app directory to path so we can import app 13 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) 14 | 15 | # Import app with its context 16 | from app import app, db, Currency 17 | 18 | def update_currency_rates(): 19 | """ 20 | Update currency exchange rates using a public API 21 | More robust rate updating mechanism 22 | """ 23 | try: 24 | # Find the base currency 25 | base_currency = Currency.query.filter_by(is_base=True).first() 26 | 27 | if not base_currency: 28 | logger.error("No base currency found. Cannot update rates.") 29 | return -1 30 | 31 | base_code = base_currency.code 32 | logger.info(f"Updating rates with base currency: {base_code}") 33 | 34 | # Use Frankfurter API 35 | api_url = f'https://api.frankfurter.app/latest?from={base_code}' 36 | 37 | try: 38 | response = requests.get(api_url, timeout=10) 39 | except requests.RequestException as req_err: 40 | logger.error(f"API request failed: {req_err}") 41 | return -1 42 | 43 | if response.status_code != 200: 44 | logger.error(f"API returned status code {response.status_code}") 45 | return -1 46 | 47 | try: 48 | data = response.json() 49 | except ValueError: 50 | logger.error("Failed to parse API response") 51 | return -1 52 | 53 | rates = data.get('rates', {}) 54 | 55 | # Always set base currency rate to 1.0 56 | base_currency.rate_to_base = 1.0 57 | 58 | # Get all currencies except base 59 | currencies = Currency.query.filter(Currency.code != base_code).all() 60 | updated_count = 0 61 | 62 | # Update rates 63 | for currency in currencies: 64 | if currency.code in rates: 65 | try: 66 | # Convert the rate to base currency rate 67 | currency.rate_to_base = 1 / rates[currency.code] 68 | currency.last_updated = datetime.utcnow() 69 | updated_count += 1 70 | 71 | logger.info(f"Updated {currency.code}: rate = {currency.rate_to_base}") 72 | except (TypeError, ZeroDivisionError) as rate_err: 73 | logger.error(f"Error calculating rate for {currency.code}: {rate_err}") 74 | 75 | # Commit changes 76 | db.session.commit() 77 | 78 | logger.info(f"Successfully updated {updated_count} currency rates") 79 | return updated_count 80 | 81 | except Exception as e: 82 | logger.error(f"Unexpected error in currency rate update: {str(e)}") 83 | return -1 --------------------------------------------------------------------------------