├── .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 |
4 |
5 |
6 | DollarDollar Bill Y'all
7 |
8 |
9 |
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 |
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 |
149 |
150 |
151 |
152 |
153 |
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 |
57 |
62 |
63 | ${message}
64 |
65 |
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 |
119 | Select category
120 | ${document.getElementById('edit_category_id')?.innerHTML || ''}
121 |
122 |
123 |
124 |
125 | ${window.baseCurrencySymbol || '$'}
126 |
128 |
129 |
130 |
131 |
132 |
133 |
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 |
52 |
53 |
76 | {% endblock %}
77 |
--------------------------------------------------------------------------------
/templates/admin.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 |
4 | {% block content %}
5 |
6 |
Admin Dashboard
7 |
8 |
9 |
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
49 |
50 | Name
51 | Email
52 | Admin Status
53 | Actions
54 |
55 |
56 |
57 | {% for user in users %}
58 |
59 | {{ user.name }}
60 | {{ user.id }}
61 |
62 | {% if user.id != current_user.id %}
63 |
74 | {% else %}
75 | Admin (You)
76 | {% endif %}
77 |
78 |
79 | {% if user.id != current_user.id %}
80 |
81 |
83 | Reset Password
84 |
85 |
88 |
89 | {% endif %}
90 |
91 |
92 |
93 | {% if user.id != current_user.id %}
94 |
95 |
96 |
131 |
132 |
133 | {% endif %}
134 | {% endfor %}
135 |
136 |
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 |
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 |
78 |
79 |
80 |
81 |
82 |
83 |
Clear Expired Cache
84 |
Remove cache entries that have passed their expiry time.
85 |
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 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
Cached Files
110 | {% if cache_files %}
111 |
112 |
113 |
114 |
115 | Cache Key
116 | Size
117 | Last Modified
118 | Status
119 |
120 |
121 |
122 | {% for file in cache_files %}
123 |
124 | {{ file.key }}
125 | {{ file.size }}
126 | {{ file.modified }}
127 |
128 | {% if file.expired %}
129 | Expired
130 | {% else %}
131 | Valid
132 | {% endif %}
133 |
134 |
135 | {% endfor %}
136 |
137 |
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 |
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 |
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 |
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 |
11 | Create Group
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {% include 'partials/create_group_form.html' %}
20 |
21 |
22 |
23 |
24 | {% for group in groups %}
25 |
26 |
27 |
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 |
22 |
23 |
24 |
25 |
26 |
27 | Date
28 | Description
29 | Amount
30 | Category
31 | Card Used
32 |
33 |
34 |
35 | {% for expense in expenses %}
36 |
37 | {{ expense.date.strftime('%Y-%m-%d') }}
38 | {{ expense.description }}
39 | {{ base_currency.symbol }}{{ "%.2f"|format(expense.amount) }}
40 |
41 | {% if expense.category %}
42 |
43 | {{ expense.category.name }}
44 |
45 | {% else %}
46 | Uncategorized
47 | {% endif %}
48 |
49 | {{ expense.card_used }}
50 |
51 | {% endfor %}
52 |
53 |
54 |
55 |
56 |
57 | {% endif %}
58 |
59 |
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 |
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 | Date
37 | Description
38 | Amount
39 | Card Used
40 | Split Method
41 | Person 1 Pays
42 | Person 2 Pays
43 | Paid By
44 |
45 |
46 |
47 | {% for expense in expenses %}
48 | {% set splits = expense.calculate_splits() %}
49 |
50 | {{ expense.date.strftime('%Y-%m-%d') }}
51 | {{ expense.description }}
52 | ${{ "%.2f"|format(expense.amount) }}
53 | {{ expense.card_used }}
54 |
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 |
60 | ${{ "%.2f"|format(splits.person1) }}
61 | ${{ "%.2f"|format(splits.person2) }}
62 | {{ expense.paid_by }}
63 |
64 | {% endfor %}
65 |
66 |
67 |
68 | {% endblock %}
69 |
--------------------------------------------------------------------------------
/templates/investments/partials/investment_details.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | Shares Owned:
12 | {{ "%.2f"|format(investment.shares) }}
13 |
14 |
15 | Average Cost:
16 | ${{ "%.2f"|format(investment.purchase_price) }}
17 |
18 |
19 | Current Price:
20 | ${{ "%.2f"|format(investment.current_price) }}
21 |
22 |
23 | Total Cost:
24 | ${{ "%.2f"|format(investment.cost_basis) }}
25 |
26 |
27 | Current Value:
28 | ${{ "%.2f"|format(investment.current_value) }}
29 |
30 |
31 | Gain/Loss:
32 |
33 | ${{ "%.2f"|format(investment.gain_loss) }} ({{ "%.2f"|format(investment.gain_loss_percentage) }}%)
34 |
35 |
36 |
37 | Purchase Date:
38 | {{ investment.purchase_date.strftime('%Y-%m-%d') }}
39 |
40 |
41 | Last Updated:
42 | {{ investment.last_update.strftime('%Y-%m-%d %H:%M') if investment.last_update else 'Never' }}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
54 |
55 | {% if stock_data %}
56 |
57 |
58 | Company:
59 | {{ stock_data.name }}
60 |
61 |
62 | Current Price:
63 | ${{ "%.2f"|format(stock_data.price) }}
64 |
65 |
66 | Daily Change:
67 |
68 | ${{ "%.2f"|format(stock_data.change) }} ({{ "%.2f"|format(stock_data.percent_change) }}%)
69 |
70 |
71 | {% if stock_data.sector %}
72 |
73 | Sector:
74 | {{ stock_data.sector }}
75 |
76 | {% endif %}
77 | {% if stock_data.industry %}
78 |
79 | Industry:
80 | {{ stock_data.industry }}
81 |
82 | {% endif %}
83 | {% if stock_data.market_cap %}
84 |
85 | Market Cap:
86 | ${{ '{:,.2f}'.format(stock_data.market_cap / 1000000000) }} B
87 |
88 | {% endif %}
89 | {% if stock_data.website %}
90 |
91 | Website:
92 | {{ stock_data.website }}
93 |
94 | {% endif %}
95 |
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 |
112 |
113 |
{{ investment.notes }}
114 |
115 |
116 | {% endif %}
117 |
118 |
119 |
122 |
123 |
124 |
125 |
126 |
127 | Date
128 | Type
129 | Shares
130 | Price
131 | Total Value
132 |
133 |
134 |
135 | {% for transaction in transactions %}
136 |
137 | {{ transaction.date.strftime('%Y-%m-%d') }}
138 |
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 |
151 | {{ "%.2f"|format(transaction.shares) }}
152 | ${{ "%.2f"|format(transaction.price) }}
153 | ${{ "%.2f"|format(transaction.transaction_value) }}
154 |
155 | {% else %}
156 |
157 |
158 | No transaction history found.
159 |
160 |
161 | {% endfor %}
162 |
163 |
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 |
17 |
18 |
19 |
20 |
21 |
22 | Date
23 | Portfolio
24 | Symbol
25 | Type
26 | Shares
27 | Price
28 | Value
29 | Notes
30 |
31 |
32 |
33 | {% for transaction in transactions %}
34 |
35 | {{ transaction.date.strftime('%Y-%m-%d') }}
36 | {{ transaction.portfolio_name }}
37 |
38 | {{ transaction.investment_symbol }}
39 | {{ transaction.investment_name }}
40 |
41 |
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 |
54 | {{ "%.2f"|format(transaction.shares) }}
55 | ${{ "%.2f"|format(transaction.price) }}
56 | ${{ "%.2f"|format(transaction.value) }}
57 |
58 | {% if transaction.notes %}
59 |
62 |
63 |
64 | {% endif %}
65 |
66 |
67 | {% else %}
68 |
69 |
70 |
71 |
72 |
73 | No investment transactions found. Add your first investment to get started.
74 |
75 | Manage Portfolios
76 |
77 |
78 |
79 | {% endfor %}
80 |
81 |
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 |
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 |
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 |
15 |
16 |
17 |
21 |
22 | {% if ignored_patterns %}
23 |
24 |
25 |
26 |
27 | Description
28 | Amount
29 | Frequency
30 | Ignored Since
31 | Actions
32 |
33 |
34 |
35 | {% for pattern in ignored_patterns %}
36 |
37 | {{ pattern.description }}
38 | {{ base_currency.symbol }}{{ "%.2f"|format(pattern.amount) }}
39 | {{ pattern.frequency|capitalize }}
40 | {{ pattern.ignore_date.strftime('%Y-%m-%d') }}
41 |
42 |
48 |
49 |
50 | {% endfor %}
51 |
52 |
53 |
54 | {% else %}
55 |
56 |
57 | You haven't ignored any recurring transaction patterns yet.
58 |
59 | {% endif %}
60 |
61 |
62 |
63 |
64 |
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 |
--------------------------------------------------------------------------------
/templates/reset_password.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 |
4 | {% block content %}
5 |
6 |
7 |
8 |
11 |
12 |
Enter your email address and we'll send you a link to reset your password.
13 |
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 |
11 |
12 |
Enter your new password below.
13 |
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 |
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 |
31 | Pay
32 |
33 |
34 |
35 | {% endfor %}
36 |
37 | {% else %}
38 |
You don't owe anyone money
39 | {% endif %}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
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 |
61 | Record
62 |
63 |
64 |
65 | {% endfor %}
66 |
67 | {% else %}
68 |
Nobody owes you money
69 | {% endif %}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
122 |
123 |
124 |
125 |
128 |
129 |
130 |
131 |
132 |
133 | Date
134 | Description
135 | From
136 | To
137 | Amount
138 |
139 |
140 |
141 | {% if settlements %}
142 | {% for settlement in settlements %}
143 |
144 | {{ settlement.date.strftime('%Y-%m-%d') }}
145 | {{ settlement.description }}
146 |
147 | {% if settlement.payer_id == current_user_id %}
148 | You
149 | {% else %}
150 | {{ settlement.payer.name }}
151 | {% endif %}
152 |
153 |
154 | {% if settlement.receiver_id == current_user_id %}
155 | You
156 | {% else %}
157 | {{ settlement.receiver.name }}
158 | {% endif %}
159 |
160 | {{ base_currency.symbol }}{{ "%.2f"|format(settlement.amount) }}
161 |
162 | {% endfor %}
163 | {% else %}
164 |
165 | No settlements yet
166 |
167 | {% endif %}
168 |
169 |
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 |
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 | Visit Financial Modeling Prep
25 | Sign up for an account (they offer a free tier with limited API calls)
26 | Once registered, go to your dashboard to find your API key
27 | Copy the key and paste it below
28 |
29 |
30 |
31 |
47 |
48 |
49 |
50 |
51 |
52 | {% endblock %}
--------------------------------------------------------------------------------
/templates/signup.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 |
4 | {% block content %}
5 |
6 |
7 |
8 |
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 |
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 |
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 |
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 |
11 | Add New Tag
12 |
13 |
14 |
15 |
16 |
17 |
18 |
40 |
41 |
42 |
43 | {% for tag in tags %}
44 |
45 |
46 |
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
--------------------------------------------------------------------------------