From fdba869a8de80ceb8166d8eab866618c47ed00ea Mon Sep 17 00:00:00 2001 From: mblanke Date: Wed, 18 Feb 2026 08:24:54 -0500 Subject: [PATCH] Version 1.1 --- .dockerignore | 2 + .env.example | 35 ++ .gitignore | 20 ++ AGENTS.md | 14 +- DOCKER_QUICKSTART.md | 289 ++++------------ DOCKER_README.md | 348 ++++++------------- Dockerfile.backend | 11 +- Dockerfile.email | 46 --- EMAIL_SETUP.md | 208 ----------- analyze_excel.py | 50 --- app.py | 573 +++++++++++++++++++++++-------- config.py | 212 ++++++++++++ docker-compose.prod.yml | 36 +- docker-compose.yml | 19 +- email_sender.py | 329 ------------------ import requests.py | 186 ---------- lottery_calculator.py | 497 +++++++++++++++++---------- megamillions_debug.html | Bin 14338 -> 0 bytes nginx.conf | 77 +++++ powerball_numbers.html | 5 - pyproject.toml | 37 ++ read_excel.py | 31 -- requirements-dev.txt | 6 + requirements.txt | 18 +- scrapers.py | 191 +++++++++++ send_email_now.py | 137 -------- ssl/README.md | 17 + test_email.py | 89 ----- tests/conftest.py | 21 ++ tests/test_api.py | 215 ++++++++++++ tests/test_config.py | 47 +++ tests/test_lottery_calculator.py | 207 +++++++++++ tests/test_scrapers.py | 111 ++++++ 33 files changed, 2142 insertions(+), 1942 deletions(-) create mode 100644 .env.example delete mode 100644 Dockerfile.email delete mode 100644 EMAIL_SETUP.md delete mode 100644 analyze_excel.py create mode 100644 config.py delete mode 100644 email_sender.py delete mode 100644 import requests.py delete mode 100644 megamillions_debug.html create mode 100644 nginx.conf delete mode 100644 powerball_numbers.html create mode 100644 pyproject.toml delete mode 100644 read_excel.py create mode 100644 requirements-dev.txt create mode 100644 scrapers.py delete mode 100644 send_email_now.py create mode 100644 ssl/README.md delete mode 100644 test_email.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_config.py create mode 100644 tests/test_lottery_calculator.py create mode 100644 tests/test_scrapers.py diff --git a/.dockerignore b/.dockerignore index 02dd7f9..cc2cc32 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ __pycache__/ .Python env/ venv/ +.venv/ ENV/ *.egg-info/ .pytest_cache/ @@ -13,6 +14,7 @@ ENV/ # Node node_modules/ +frontend/node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5de9353 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================ +# Lottery Tracker β€” Environment Configuration +# ============================================================ +# Copy this file to .env and adjust values as needed. +# cp .env.example .env +# ============================================================ + +# --- Flask --- +FLASK_DEBUG=false +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 + +# CORS: comma-separated origins, or * for all +ALLOWED_ORIGINS=* + +# --- Tax rates --- +LUMP_SUM_RATE=0.52 +FEDERAL_TAX_RATE=0.37 +DEFAULT_STATE_TAX_RATE=0.055 +USD_CAD_RATE=1.35 +INVESTMENT_INCOME_TAX_RATE=0.5353 +PERSONAL_WITHDRAWAL_PCT=0.10 + +# --- Investment defaults --- +DEFAULT_INVEST_PCT=0.90 +DEFAULT_ANNUAL_RETURN=0.045 +DEFAULT_CYCLES=8 + +# --- Scraper URLs (override only if sites change) --- +# SCRAPER_URL_POWERBALL=https://www.lotto.net/powerball +# SCRAPER_URL_MEGA_MILLIONS=https://www.lotto.net/mega-millions +# SCRAPER_URL_OLG=https://www.olg.ca/ + +# --- Cache TTL (seconds, default 6 hours) --- +CACHE_TTL_SECONDS=21600 diff --git a/.gitignore b/.gitignore index d7d02af..cbe798b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,23 @@ Thumbs.db # Node node_modules/ +.next/ +out/ + +# Data files +*.xlsx +*.xls +*.csv + +# Debug HTML +*.html + +# Docker +ssl/*.pem +ssl/*.key +ssl/*.crt + +# Coverage +htmlcov/ +.coverage +coverage.xml diff --git a/AGENTS.md b/AGENTS.md index fb9c97a..a025db7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,13 +64,13 @@ Model: Auto (override to the strongest available for high-stakes diffs). - Prefer deterministic, reproducible steps. - Cite sources when generating documents from a knowledge base. -## Repo facts (fill these in) -- Primary stack: -- Package manager: -- Test command: -- Lint/format command: -- Build command (if any): -- Deployment (if any): +## Repo facts +- Primary stack: Python 3.13 (Flask 3.1) + TypeScript (Next.js 15, MUI 6) +- Package manager: pip (backend) + npm (frontend) +- Test command: `pytest -q` +- Lint/format command: `ruff check . --fix` +- Build command (if any): `docker compose build` +- Deployment (if any): `docker compose -f docker-compose.prod.yml up -d` ## Claude Code Agents (optional) diff --git a/DOCKER_QUICKSTART.md b/DOCKER_QUICKSTART.md index d83d751..dc6bcde 100644 --- a/DOCKER_QUICKSTART.md +++ b/DOCKER_QUICKSTART.md @@ -1,255 +1,92 @@ -# πŸ‹ Docker Setup Complete! +# Docker Quick Start -## What's Been Created +## What's Included ### Docker Files -- βœ… `Dockerfile.backend` - Flask API with Playwright -- βœ… `Dockerfile.frontend` - Next.js app optimized for production -- βœ… `Dockerfile.email` - Email scheduler service -- βœ… `docker-compose.yml` - Development setup -- βœ… `docker-compose.prod.yml` - Production setup with nginx -- βœ… `.dockerignore` - Optimized build context -- βœ… `requirements.txt` - Python dependencies - -### Configuration -- βœ… Updated `next.config.ts` for standalone output -- βœ… Created startup scripts (Windows & Linux) -- βœ… Complete documentation in `DOCKER_README.md` +- `Dockerfile.backend` β€” Flask API with Playwright & Chromium +- `Dockerfile.frontend` β€” Next.js standalone production build +- `docker-compose.yml` β€” Development setup (backend + frontend) +- `docker-compose.prod.yml` β€” Production setup (+ nginx reverse proxy) +- `nginx.conf` β€” Reverse proxy with rate limiting & caching +- `.env.example` β€” All available environment variables --- -## πŸš€ Quick Start +## Quick Start -### Option 1: Windows Script (Easiest) +### 1. Configure ```bash +cp .env.example .env +``` + +### 2. Start +```bash +# Windows docker-start.bat + +# Linux/macOS +./docker-start.sh + +# Or directly +docker compose up -d ``` -### Option 2: Docker Compose -```bash -docker-compose up -d -``` - -### Option 3: Manual Build -```bash -# Build -docker-compose build - -# Start -docker-compose up -d - -# Check status -docker-compose ps -``` - ---- - -## πŸ“¦ What Gets Deployed - -### Backend Container -- Python 3.13 -- Flask API on port 5000 -- Playwright with Chromium browser -- Lottery scrapers for all 4 lotteries -- Investment calculator -- Health check endpoint - -### Frontend Container -- Node.js 20 -- Next.js standalone build -- Optimized production bundle -- Connects to backend API - -### Email Container (Optional) -- Runs daily at 7:00 AM -- Sends lottery jackpot emails -- Uses same scraping logic - ---- - -## 🌐 Access Points - -After running `docker-compose up -d`: - +### 3. Open - **Frontend**: http://localhost:3000 - **Backend API**: http://localhost:5000 - **Health Check**: http://localhost:5000/api/health --- -## πŸ“Š Container Management +## What Gets Deployed + +### Backend Container +- Python 3.13 + Flask + Gunicorn (2 workers) +- Playwright with Chromium (for Canadian lottery scraping) +- Unified scraper for Powerball, Mega Millions, Lotto Max, Lotto 6/49 +- Investment calculator with tax, annuity, group play, break-even +- TTL-cached jackpot data (6 hours default) + +### Frontend Container +- Node.js 20 + Next.js 15 standalone build +- Material-UI dark theme +- 6 interactive pages (Calculator, Compare, Break-Even, Annuity, Group Play, Odds) + +### Nginx (Production Only) +- Reverse proxy for API and frontend +- Rate limiting (10 req/s burst 20) +- Static asset caching (30 days) +- HTTPS ready (see `ssl/README.md`) + +--- + +## Common Commands -### View Logs ```bash -# All services -docker-compose logs -f +# View logs +docker compose logs -f -# Specific service -docker-compose logs -f backend -docker-compose logs -f frontend -``` +# Restart +docker compose restart -### Restart Services -```bash -docker-compose restart -``` +# Rebuild after code changes +docker compose up -d --build -### Stop Everything -```bash -docker-compose down -``` +# Stop +docker compose down -### Rebuild After Changes -```bash -docker-compose up -d --build +# Production +docker compose -f docker-compose.prod.yml up -d ``` --- -## πŸ”§ Troubleshooting +## Troubleshooting -### Port Already in Use -If ports 3000 or 5000 are busy: +1. **Check Docker Desktop is running** +2. **Ensure ports 3000 and 5000 are available** +3. **Check logs**: `docker compose logs` +4. **Rebuild**: `docker compose up -d --build` +5. **Reset**: `docker compose down && docker compose up -d` -**Option A**: Stop other services -```bash -# Windows -netstat -ano | findstr :3000 -taskkill /PID /F -``` - -**Option B**: Change ports in `docker-compose.yml` -```yaml -ports: - - "8080:3000" # Use port 8080 instead -``` - -### Backend Won't Start -```bash -# Check logs -docker logs lottery-backend - -# Rebuild without cache -docker-compose build --no-cache backend -``` - -### Frontend Can't Connect -Update `docker-compose.yml` frontend environment: -```yaml -environment: - - NEXT_PUBLIC_API_URL=http://localhost:5000 -``` - ---- - -## 🎯 Include Email Scheduler - -To run the email scheduler: - -```bash -docker-compose --profile email up -d -``` - -Or remove the `profiles` section from `docker-compose.yml` to always include it. - ---- - -## πŸ“ˆ Production Deployment - -### Use Production Compose -```bash -docker-compose -f docker-compose.prod.yml up -d -``` - -### Deploy to Server -```bash -# On your server -git clone -cd Lottery -docker-compose -f docker-compose.prod.yml up -d -``` - -### Push to Docker Hub -```bash -# Login -docker login - -# Tag images -docker tag lottery-backend yourusername/lottery-backend:latest -docker tag lottery-frontend yourusername/lottery-frontend:latest - -# Push -docker push yourusername/lottery-backend:latest -docker push yourusername/lottery-frontend:latest -``` - ---- - -## πŸ”’ Security for Production - -1. **Use environment variables** - Don't hardcode credentials -2. **Enable HTTPS** - Use nginx with SSL certificates -3. **Update base images** regularly -4. **Scan for vulnerabilities**: - ```bash - docker scan lottery-backend - ``` -5. **Use Docker secrets** for sensitive data - ---- - -## πŸ’Ύ Data Persistence - -Currently, containers are stateless. To add persistence: - -Add volumes in `docker-compose.yml`: -```yaml -volumes: - - ./data:/app/data -``` - ---- - -## πŸŽ›οΈ Resource Limits - -Current limits (production): -- Backend: 2GB RAM, 1 CPU -- Frontend: 512MB RAM, 0.5 CPU -- Email: 1GB RAM, 0.5 CPU - -Adjust in `docker-compose.prod.yml` if needed. - ---- - -## βœ… Benefits of Docker - -1. βœ… **Consistent environment** - Works the same everywhere -2. βœ… **Easy deployment** - One command to start everything -3. βœ… **Isolation** - Each service in its own container -4. βœ… **Scalability** - Easy to scale services -5. βœ… **Version control** - Docker images are versioned -6. βœ… **Portability** - Deploy anywhere Docker runs - ---- - -## πŸ“ Next Steps - -1. βœ… Test locally: `docker-compose up -d` -2. βœ… Check logs: `docker-compose logs -f` -3. βœ… Access app: http://localhost:3000 -4. βœ… Configure email scheduler if needed -5. βœ… Deploy to production server -6. βœ… Set up CI/CD pipeline (optional) - ---- - -## πŸ†˜ Need Help? - -See detailed documentation in: -- `DOCKER_README.md` - Full Docker guide -- `EMAIL_SETUP.md` - Email configuration -- Docker logs: `docker-compose logs -f` - ---- - -Enjoy your Dockerized Lottery Investment Calculator! πŸŽ°πŸ‹ +See `DOCKER_README.md` for detailed troubleshooting. diff --git a/DOCKER_README.md b/DOCKER_README.md index e46fd7c..91f9467 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,54 +1,56 @@ # Lottery Investment Calculator - Docker Setup -## πŸ‹ Docker Deployment Guide - -### Prerequisites +## Prerequisites - Docker Desktop installed (https://www.docker.com/products/docker-desktop) - Docker Compose (included with Docker Desktop) --- -## πŸš€ Quick Start +## Quick Start -### 1. Build and Run Everything +### 1. Configure Environment ```bash -docker-compose up -d +cp .env.example .env +# Edit .env with your preferred settings ``` -This will start: +### 2. Build and Run +```bash +docker compose up -d +``` + +This starts: - **Backend API** on http://localhost:5000 - **Frontend Web App** on http://localhost:3000 -### 2. Check Status +### 3. Check Status ```bash -docker-compose ps +docker compose ps ``` -### 3. View Logs +### 4. View Logs ```bash # All services -docker-compose logs -f +docker compose logs -f -# Just backend -docker-compose logs -f backend - -# Just frontend -docker-compose logs -f frontend +# Specific service +docker compose logs -f backend +docker compose logs -f frontend ``` -### 4. Stop Everything +### 5. Stop ```bash -docker-compose down +docker compose down ``` --- -## πŸ“¦ Individual Services +## Individual Services ### Backend Only ```bash docker build -f Dockerfile.backend -t lottery-backend . -docker run -p 5000:5000 lottery-backend +docker run -p 5000:5000 --env-file .env lottery-backend ``` ### Frontend Only @@ -57,291 +59,139 @@ docker build -f Dockerfile.frontend -t lottery-frontend . docker run -p 3000:3000 lottery-frontend ``` -### Email Scheduler (Optional) -```bash -docker-compose --profile email up -d -``` +--- + +## Configuration + +All configuration is done via environment variables. See `.env.example` for available options. + +Key variables: +| Variable | Default | Description | +|----------|---------|-------------| +| `FLASK_DEBUG` | `false` | Enable Flask debug mode | +| `FLASK_CORS_ORIGINS` | `*` | Allowed CORS origins | +| `FEDERAL_TAX_RATE` | `0.37` | US federal tax rate | +| `DEFAULT_STATE_TAX_RATE` | `0.055` | Default state tax rate | +| `USD_TO_CAD` | `1.44` | USDβ†’CAD exchange rate | +| `CACHE_TTL_HOURS` | `6` | Jackpot cache duration | --- -## πŸ”§ Configuration +## Network -### Update Next.js to use standalone output - -Add to `frontend/next.config.ts`: -```typescript -const nextConfig = { - output: 'standalone', -}; -``` - -### Environment Variables - -Create `.env` file: -```bash -# Backend -FLASK_ENV=production - -# Frontend -NEXT_PUBLIC_API_URL=http://localhost:5000 - -# Email (optional) -EMAIL_SENDER=mblanke@gmail.com -EMAIL_RECIPIENT=mblanke@gmail.com -EMAIL_PASSWORD=vyapvyjjfrqpqnax -``` - -Then update `docker-compose.yml` to use env_file: -```yaml -services: - backend: - env_file: .env -``` - ---- - -## πŸ—οΈ Build Process - -### First Time Setup -```bash -# Build all images -docker-compose build - -# Or build individually -docker-compose build backend -docker-compose build frontend -docker-compose build email-scheduler -``` - -### Rebuild After Code Changes -```bash -# Rebuild and restart -docker-compose up -d --build - -# Rebuild specific service -docker-compose up -d --build backend -``` - ---- - -## 🌐 Network Configuration - -All services communicate via the `lottery-network` bridge network. - -### Internal URLs (container to container): +### Internal URLs (container to container) - Backend: `http://backend:5000` - Frontend: `http://frontend:3000` -### External URLs (host to container): +### External URLs (host machine) - Backend: `http://localhost:5000` - Frontend: `http://localhost:3000` --- -## πŸ“Š Health Checks +## Health Checks -The backend includes a health check endpoint: ```bash curl http://localhost:5000/api/health ``` -Check in Docker: -```bash -docker inspect lottery-backend | grep -A 10 Health -``` - --- -## πŸ”„ Production Deployment +## Production Deployment -### Docker Hub +### Using Production Compose ```bash -# Tag images +docker compose -f docker-compose.prod.yml up -d +``` + +This adds nginx reverse proxy with: +- Rate limiting (10 req/s) +- Static asset caching +- HTTPS support (configure certs in `ssl/`) +- Resource limits per container + +See `ssl/README.md` for certificate setup. + +### Deploy to Server +```bash +git clone +cd Lottery-Tracker +cp .env.example .env +# Edit .env for production +docker compose -f docker-compose.prod.yml up -d +``` + +### Push to Docker Hub +```bash +docker login docker tag lottery-backend yourusername/lottery-backend:latest docker tag lottery-frontend yourusername/lottery-frontend:latest - -# Push to Docker Hub docker push yourusername/lottery-backend:latest docker push yourusername/lottery-frontend:latest ``` -### Deploy to Server -```bash -# Pull images on server -docker pull yourusername/lottery-backend:latest -docker pull yourusername/lottery-frontend:latest - -# Run with compose -docker-compose -f docker-compose.prod.yml up -d -``` - --- -## πŸ› Troubleshooting +## Troubleshooting -### Backend won't start +### Port Already in Use ```bash -# Check logs -docker logs lottery-backend +# Windows +netstat -ano | findstr :5000 +taskkill /PID /F -# Common issues: -# - Port 5000 already in use -# - Playwright installation failed -# - Missing dependencies +# Or change ports in docker-compose.yml ``` -### Frontend can't connect to backend +### Backend Won't Start ```bash -# Check if backend is running -docker-compose ps +docker logs lottery-backend +docker compose build --no-cache backend +``` -# Test backend directly +### Frontend Can't Connect to Backend +```bash +docker compose ps curl http://localhost:5000/api/health - -# Check frontend environment docker exec lottery-frontend env | grep API_URL ``` -### Playwright browser issues +### Playwright Browser Issues ```bash -# Rebuild with no cache -docker-compose build --no-cache backend - -# Check Playwright installation +docker compose build --no-cache backend docker exec lottery-backend playwright --version ``` -### Container keeps restarting +### Access Container Shell ```bash -# View logs -docker logs lottery-backend --tail 100 +docker exec -it lottery-backend /bin/bash +docker exec -it lottery-frontend /bin/sh +``` -# Check health status -docker inspect lottery-backend | grep -A 5 Health +### Clean Everything +```bash +docker compose down -v --rmi all +docker system prune -a ``` --- -## πŸ“ Useful Commands +## Resource Usage -### Access Container Shell -```bash -# Backend -docker exec -it lottery-backend /bin/bash +Production limits (set in `docker-compose.prod.yml`): +- Backend: 2 GB RAM, 1 CPU +- Frontend: 512 MB RAM, 0.5 CPU +- Nginx: 256 MB RAM, 0.25 CPU -# Frontend -docker exec -it lottery-frontend /bin/sh -``` - -### Remove Everything -```bash -# Stop and remove containers, networks -docker-compose down - -# Also remove volumes -docker-compose down -v - -# Remove images -docker-compose down --rmi all -``` - -### Prune Unused Resources -```bash -docker system prune -a -``` - -### View Resource Usage +### Monitor ```bash docker stats ``` --- -## 🚒 Alternative: Docker without Compose +## Image Sizes (Approximate) -### Create Network -```bash -docker network create lottery-network -``` - -### Run Backend -```bash -docker run -d \ - --name lottery-backend \ - --network lottery-network \ - -p 5000:5000 \ - lottery-backend -``` - -### Run Frontend -```bash -docker run -d \ - --name lottery-frontend \ - --network lottery-network \ - -p 3000:3000 \ - -e NEXT_PUBLIC_API_URL=http://localhost:5000 \ - lottery-frontend -``` - ---- - -## 🎯 Email Scheduler with Docker - -To include the email scheduler: - -1. **Start with email service:** -```bash -docker-compose --profile email up -d -``` - -2. **Or add to default profile** (edit docker-compose.yml): -Remove `profiles: - email` from email-scheduler service - -3. **Check email logs:** -```bash -docker logs lottery-email -f -``` - ---- - -## πŸ” Security Notes - -⚠️ **Important:** -- Never commit `.env` files with real credentials -- Use Docker secrets in production -- Set proper firewall rules -- Use HTTPS in production -- Regularly update base images - ---- - -## πŸ“ˆ Scaling - -### Run multiple backend instances -```bash -docker-compose up -d --scale backend=3 -``` - -### Add load balancer (nginx) -See `docker-compose.prod.yml` for nginx configuration - ---- - -## πŸ†˜ Support - -If containers won't start: -1. Check Docker Desktop is running -2. Ensure ports 3000 and 5000 are available -3. Check logs: `docker-compose logs` -4. Rebuild: `docker-compose up -d --build` -5. Reset: `docker-compose down && docker-compose up -d` - ---- - -## πŸ“¦ Image Sizes (Approximate) - -- Backend: ~1.5 GB (includes Chromium browser) -- Frontend: ~200 MB -- Email Scheduler: ~1.5 GB (includes Chromium browser) - -To reduce size, consider multi-stage builds or Alpine Linux variants. +- Backend: ~1.5 GB (includes Chromium for Playwright) +- Frontend: ~200 MB (Next.js standalone) +- Nginx: ~30 MB diff --git a/Dockerfile.backend b/Dockerfile.backend index 92d60a1..a41c175 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -36,14 +36,14 @@ COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Install Playwright browsers +# Install Playwright Chromium (deps already installed above) RUN playwright install chromium -RUN playwright install-deps chromium # Copy application files COPY app.py . COPY lottery_calculator.py . -COPY ["import requests.py", "."] +COPY config.py . +COPY scrapers.py . # Expose port EXPOSE 5000 @@ -51,7 +51,8 @@ EXPOSE 5000 # Set environment variables ENV FLASK_APP=app.py ENV FLASK_ENV=production +ENV FLASK_DEBUG=false ENV PYTHONUNBUFFERED=1 -# Run the application -CMD ["python", "app.py"] +# Run with gunicorn for production +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"] diff --git a/Dockerfile.email b/Dockerfile.email deleted file mode 100644 index 4d28b59..0000000 --- a/Dockerfile.email +++ /dev/null @@ -1,46 +0,0 @@ -# Email Scheduler Dockerfile -FROM python:3.13-slim - -WORKDIR /app - -# Install system dependencies for Playwright -RUN apt-get update && apt-get install -y \ - wget \ - gnupg \ - ca-certificates \ - fonts-liberation \ - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libatspi2.0-0 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libgbm1 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libwayland-client0 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxkbcommon0 \ - libxrandr2 \ - xdg-utils \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements and install -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Install Playwright -RUN playwright install chromium -RUN playwright install-deps chromium - -# Copy email sender script -COPY email_sender.py . - -# Set environment variables -ENV PYTHONUNBUFFERED=1 - -CMD ["python", "email_sender.py"] diff --git a/EMAIL_SETUP.md b/EMAIL_SETUP.md deleted file mode 100644 index f832d2b..0000000 --- a/EMAIL_SETUP.md +++ /dev/null @@ -1,208 +0,0 @@ -# Email Configuration for Lottery Jackpot Alerts - -## Setup Instructions - -### 1. Install Required Package -```bash -pip install schedule -``` - -### 2. Configure Email Settings - -Edit `email_sender.py` and update the `EMAIL_CONFIG` section: - -```python -EMAIL_CONFIG = { - 'smtp_server': 'smtp.gmail.com', # Your email provider's SMTP server - 'smtp_port': 587, - 'sender_email': 'your-email@gmail.com', # Your email address - 'sender_password': 'your-app-password', # Your app-specific password - 'recipient_email': 'recipient@example.com', # Where to send the report -} -``` - -### 3. Email Provider Settings - -#### For Gmail: -1. **Enable 2-Factor Authentication** on your Google account -2. **Generate App Password**: - - Go to: https://myaccount.google.com/apppasswords - - Select "Mail" and "Windows Computer" - - Copy the 16-character password - - Use this password in `sender_password` (NOT your regular Gmail password) -3. SMTP Settings: - - Server: `smtp.gmail.com` - - Port: `587` - -#### For Outlook/Hotmail: -- Server: `smtp-mail.outlook.com` -- Port: `587` -- Use your regular email and password - -#### For Yahoo: -- Server: `smtp.mail.yahoo.com` -- Port: `587` -- Generate app password at: https://login.yahoo.com/account/security - -#### For Other Providers: -Search for "[Your Provider] SMTP settings" to find the correct server and port. - -### 4. Test the Email - -Uncomment this line in the `main()` function to send a test email immediately: -```python -send_daily_jackpots() -``` - -Then run: -```bash -python email_sender.py -``` - -### 5. Schedule Daily Emails - -The script is configured to send emails at **7:00 AM** every day. - -To run it continuously: -```bash -python email_sender.py -``` - -Keep the terminal window open. The script will: -- Wait until 7:00 AM -- Fetch current jackpots -- Send formatted email -- Repeat daily - -### 6. Run as Background Service (Optional) - -#### Windows - Task Scheduler: -1. Open Task Scheduler -2. Create Basic Task -3. Name: "Lottery Jackpot Email" -4. Trigger: Daily at 7:00 AM -5. Action: Start a program - - Program: `python` - - Arguments: `d:\Projects\Dev\Lottery\email_sender.py` -6. Finish - -#### Windows - NSSM (Non-Sucking Service Manager): -```bash -# Install NSSM -choco install nssm - -# Create service -nssm install LotteryEmail python d:\Projects\Dev\Lottery\email_sender.py - -# Start service -nssm start LotteryEmail -``` - -#### Linux - Cron Job: -```bash -# Edit crontab -crontab -e - -# Add this line (runs at 7:00 AM daily) -0 7 * * * /usr/bin/python3 /path/to/email_sender.py -``` - -#### Linux - systemd service: -Create `/etc/systemd/system/lottery-email.service`: -```ini -[Unit] -Description=Lottery Jackpot Email Service -After=network.target - -[Service] -Type=simple -User=yourusername -WorkingDirectory=/path/to/Lottery -ExecStart=/usr/bin/python3 /path/to/email_sender.py -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -Then: -```bash -sudo systemctl enable lottery-email -sudo systemctl start lottery-email -``` - -## Email Features - -The automated email includes: -- 🎰 **Powerball** jackpot (US) -- 🎰 **Mega Millions** jackpot (US) -- 🎰 **Lotto Max** jackpot (Canada - TAX FREE!) -- 🎰 **Lotto 6/49** jackpot (Canada - TAX FREE!) -- πŸ“… **Timestamp** of when data was fetched -- πŸ’‘ **Reminder** about Canadian tax-free winnings -- 🎨 **Beautiful HTML formatting** with colors and styling - -## Customization - -### Change Send Time: -Edit this line in `email_sender.py`: -```python -schedule.every().day.at("07:00").do(send_daily_jackpots) -``` - -Examples: -- `"09:30"` - 9:30 AM -- `"18:00"` - 6:00 PM -- `"00:00"` - Midnight - -### Send to Multiple Recipients: -Change the `send_email()` function: -```python -msg['To'] = "email1@example.com, email2@example.com, email3@example.com" -``` - -### Send Multiple Times Per Day: -Add multiple schedule lines: -```python -schedule.every().day.at("07:00").do(send_daily_jackpots) -schedule.every().day.at("19:00").do(send_daily_jackpots) -``` - -## Troubleshooting - -### "Authentication failed": -- Make sure you're using an **app password**, not your regular password (for Gmail) -- Check that 2FA is enabled on your account -- Verify SMTP server and port are correct - -### "Connection refused": -- Check your firewall settings -- Verify SMTP port is correct (usually 587 or 465) -- Try port 465 with `SMTP_SSL` instead of `SMTP` with `starttls()` - -### Script stops running: -- Check if your computer went to sleep -- Use Task Scheduler or systemd to auto-restart -- Check logs for error messages - -### Jackpots not updating: -- Websites may have changed their HTML structure -- Check if Playwright browser is installed: `playwright install chromium` -- Test the scraper functions individually - -## Security Notes - -⚠️ **IMPORTANT**: -- Never commit `email_sender.py` with your real credentials to Git -- Use environment variables for sensitive data in production -- Keep your app password secure -- Don't share your app password with anyone - -## Support - -If you encounter issues: -1. Run the test email first to verify configuration -2. Check error messages in the console -3. Verify internet connection -4. Confirm email provider settings -5. Test scraping functions individually diff --git a/analyze_excel.py b/analyze_excel.py deleted file mode 100644 index d674937..0000000 --- a/analyze_excel.py +++ /dev/null @@ -1,50 +0,0 @@ -import openpyxl - -wb = openpyxl.load_workbook('Max.xlsx', data_only=True) -ws = wb.active - -print("LOTTERY INVESTMENT CALCULATOR ANALYSIS") -print("="*70) -print("\nINPUTS:") -print("-"*70) -print(f"Lottery Amount: ${ws['D3'].value:,.0f}") -print(f"Cash Sum (52%): ${ws['D4'].value:,.2f}") -print(f"Federal Taxes (37%): ${ws['D5'].value:,.2f}") -print(f"State Taxes (5.5%): ${ws['D6'].value:,.2f}") -print(f"Net Amount: ${ws['D7'].value:,.2f}") -print(f"Canadian Conversion (1.35x): ${ws['D8'].value:,.2f}") -print(f"\nInvest 90%: ${ws['D10'].value:,.2f}") -print(f"Fun Money (10%): ${ws['G7'].value:,.2f}") -print(f"Net Daily Income: ${ws['G8'].value:,.2f}") - -print("\n\nINVESTMENT CYCLES (90-day periods at 4.5% annual return):") -print("-"*70) -print(f"{'Cycle':<10} {'Principal Start':<18} {'Interest':<15} {'Taxes':<15} {'Withdrawal':<15} {'Total Out':<15} {'Reinvest':<15} {'Principal End':<18}") -print("-"*70) - -for row in range(13, 21): # Cycles 1-8 - cycle = ws[f'C{row}'].value - principal_start = ws[f'D{row}'].value - interest = ws[f'E{row}'].value - taxes = ws[f'F{row}'].value - withdrawal = ws[f'G{row}'].value - total_out = ws[f'H{row}'].value - reinvest = ws[f'I{row}'].value - principal_end = ws[f'J{row}'].value - - print(f"{cycle:<10} ${principal_start:>15,.0f} ${interest:>13,.0f} ${taxes:>13,.0f} ${withdrawal:>13,.0f} ${total_out:>13,.0f} ${reinvest:>13,.0f} ${principal_end:>15,.0f}") - -print("\n\nKEY FORMULAS:") -print("-"*70) -print("β€’ Interest per cycle: Principal Γ— 4.5% Γ— (90/365)") -print("β€’ Taxes on interest: Interest Γ— 53.53%") -print("β€’ Personal withdrawal: Interest Γ— 10%") -print("β€’ Total withdrawal: Taxes + Personal withdrawal") -print("β€’ Reinvestment: Interest - Total withdrawal") -print("β€’ Next cycle principal: Previous principal + Reinvestment") - -total_withdrawn = ws['G7'].value -print(f"\n\nTOTAL PERSONAL WITHDRAWALS (8 cycles): ${total_withdrawn:,.2f}") -print(f"Average per cycle: ${total_withdrawn/8:,.2f}") -print(f"Daily income: ${ws['G8'].value:,.2f}") -print(f"Annual income: ${ws['G8'].value * 365:,.2f}") diff --git a/app.py b/app.py index 81cd973..b3d909d 100644 --- a/app.py +++ b/app.py @@ -1,172 +1,443 @@ """ -Flask Backend for Lottery Investment Calculator -Provides API endpoints for jackpots and investment calculations +Flask Backend for Lottery Investment Calculator. + +API endpoints for jackpots, investment calculations, comparisons, +break-even analysis, annuity projections, and state tax information. """ +from __future__ import annotations + +import logging + from flask import Flask, jsonify, request from flask_cors import CORS -import requests -from bs4 import BeautifulSoup -import urllib3 -from playwright.sync_api import sync_playwright -import re -from lottery_calculator import calculate_us_lottery, calculate_canadian_lottery -# Suppress SSL warnings -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +from config import ( + ANNUITY_ANNUAL_INCREASE, + ANNUITY_YEARS, + LOTTERY_ODDS, + STATE_TAX_RATES, + load_config, +) +from lottery_calculator import ( + calculate_annuity, + calculate_break_even, + calculate_canadian_lottery, + calculate_group_split, + calculate_us_lottery, +) +from scrapers import clear_cache, get_all_jackpots -app = Flask(__name__) -CORS(app) # Enable CORS for Next.js frontend +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) -# Common headers to mimic a browser request -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Cache-Control": "max-age=0", -} +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- -def get_us_lotteries(): - """Fetch Powerball and Mega Millions jackpots""" - results = {"Powerball": None, "Mega Millions": None} - - # Powerball - try: - resp = requests.get("https://www.lotto.net/powerball", timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - # Extract numeric value - match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', next_line) - if match: - value = float(match.group(1).replace(',', '')) - results["Powerball"] = value * 1_000_000 - break - except Exception as e: - print(f"Error fetching Powerball: {e}") - - # Mega Millions - try: - resp = requests.get("https://www.lotto.net/mega-millions", timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - # Extract numeric value - match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', next_line) - if match: - value = float(match.group(1).replace(',', '')) - results["Mega Millions"] = value * 1_000_000 - break - except Exception as e: - print(f"Error fetching Mega Millions: {e}") - - return results +def create_app() -> Flask: + """Application factory β€” creates and configures the Flask app.""" + cfg = load_config() + app = Flask(__name__) -def get_canadian_lotteries(): - """Fetch Lotto Max and Lotto 6/49 jackpots using Playwright""" - results = {"Lotto Max": None, "Lotto 6/49": None} - - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto("https://www.olg.ca/", wait_until="networkidle", timeout=30000) - page.wait_for_timeout(3000) - content = page.content() - browser.close() - - # Lotto Max - lotto_max_match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if lotto_max_match: - value = float(lotto_max_match.group(1).replace(',', '')) - results["Lotto Max"] = value * 1_000_000 - - # Lotto 6/49 - lotto_649_match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if lotto_649_match: - value = float(lotto_649_match.group(1).replace(',', '')) - results["Lotto 6/49"] = value * 1_000_000 - except Exception as e: - print(f"Error fetching Canadian lotteries: {e}") - - return results + # CORS β€” restrict origins via env or allow all in dev + if cfg.allowed_origins == "*": + CORS(app) + else: + CORS(app, origins=[o.strip() for o in cfg.allowed_origins.split(",")]) + # ------------------------------------------------------------------ + # Validation helpers + # ------------------------------------------------------------------ -@app.route('/api/jackpots', methods=['GET']) -def get_jackpots(): - """API endpoint to get all lottery jackpots""" - us_lotteries = get_us_lotteries() - canadian_lotteries = get_canadian_lotteries() - - return jsonify({ - "us": { - "powerball": us_lotteries["Powerball"], - "megaMillions": us_lotteries["Mega Millions"] - }, - "canadian": { - "lottoMax": canadian_lotteries["Lotto Max"], - "lotto649": canadian_lotteries["Lotto 6/49"] + def _require_json() -> dict | None: + """Parse JSON body or return None.""" + return request.get_json(silent=True) + + def _validate_number( + value, name: str, *, minimum: float = 0, maximum: float | None = None + ) -> float | None: + """Coerce *value* to float and validate range. Returns None on bad input.""" + try: + v = float(value) + except (TypeError, ValueError): + return None + if v < minimum: + return None + if maximum is not None and v > maximum: + return None + return v + + # ------------------------------------------------------------------ + # Jackpot endpoints + # ------------------------------------------------------------------ + + @app.route("/api/jackpots", methods=["GET"]) + def get_jackpots(): + """Return current jackpots for all four lotteries (cached).""" + force = request.args.get("refresh", "").lower() in ("1", "true") + data = get_all_jackpots(force_refresh=force) + return jsonify(data) + + @app.route("/api/jackpots/refresh", methods=["POST"]) + def refresh_jackpots(): + """Force-refresh the jackpot cache and return new values.""" + clear_cache() + data = get_all_jackpots(force_refresh=True) + return jsonify(data) + + # ------------------------------------------------------------------ + # Calculator endpoints + # ------------------------------------------------------------------ + + @app.route("/api/calculate", methods=["POST"]) + def calculate(): + """Calculate investment returns for a given jackpot.""" + data = _require_json() + if data is None: + return jsonify({"error": "Request body must be JSON"}), 400 + + jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1) + if jackpot is None: + return jsonify({"error": "jackpot must be a positive number"}), 400 + + lottery_type = data.get("type", "us") + if lottery_type not in ("us", "canadian"): + return jsonify({"error": "type must be 'us' or 'canadian'"}), 400 + + invest_pct = _validate_number( + data.get("investPercentage", cfg.investment.invest_percentage), + "investPercentage", + minimum=0, + maximum=1, + ) + annual_return = _validate_number( + data.get("annualReturn", cfg.investment.annual_return), + "annualReturn", + minimum=0, + maximum=1, + ) + cycles = _validate_number( + data.get("cycles", cfg.investment.cycles), + "cycles", + minimum=1, + maximum=100, + ) + + # State tax for US calculations + state_code = data.get("state") + state_tax = cfg.tax.default_state_tax_rate + if lottery_type == "us" and state_code: + state_info = STATE_TAX_RATES.get(state_code.upper()) + if state_info: + state_tax = state_info["rate"] + + if invest_pct is None or annual_return is None or cycles is None: + return jsonify({"error": "Invalid parameter values"}), 400 + + try: + if lottery_type == "us": + result = calculate_us_lottery( + jackpot, + invest_percentage=invest_pct, + annual_return=annual_return, + cycles=int(cycles), + state_tax_rate=state_tax, + lump_sum_rate=cfg.tax.lump_sum_rate, + federal_tax_rate=cfg.tax.federal_tax_rate, + usd_cad_rate=cfg.tax.usd_cad_rate, + investment_income_tax_rate=cfg.tax.investment_income_tax_rate, + personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct, + ) + else: + result = calculate_canadian_lottery( + jackpot, + invest_percentage=invest_pct, + annual_return=annual_return, + cycles=int(cycles), + investment_income_tax_rate=cfg.tax.investment_income_tax_rate, + personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct, + ) + return jsonify(result) + except Exception: + logger.exception("Calculation error") + return jsonify({"error": "Internal calculation error"}), 500 + + # ------------------------------------------------------------------ + # State tax endpoints + # ------------------------------------------------------------------ + + @app.route("/api/states", methods=["GET"]) + def get_states(): + """Return all US states with their lottery tax rates.""" + states = [ + {"code": code, "name": info["name"], "rate": info["rate"]} + for code, info in sorted(STATE_TAX_RATES.items()) + ] + return jsonify(states) + + @app.route("/api/states/", methods=["GET"]) + def get_state(code: str): + """Return tax info for a specific state.""" + info = STATE_TAX_RATES.get(code.upper()) + if not info: + return jsonify({"error": f"Unknown state code: {code}"}), 404 + return jsonify({"code": code.upper(), "name": info["name"], "rate": info["rate"]}) + + # ------------------------------------------------------------------ + # Comparison endpoint + # ------------------------------------------------------------------ + + @app.route("/api/compare", methods=["GET"]) + def compare(): + """Side-by-side comparison of all lotteries with current jackpots.""" + jackpots = get_all_jackpots() + state_code = request.args.get("state") + state_tax = cfg.tax.default_state_tax_rate + if state_code: + st = STATE_TAX_RATES.get(state_code.upper()) + if st: + state_tax = st["rate"] + + comparisons = [] + lottery_map = { + "powerball": ("us", jackpots["us"].get("powerball")), + "megaMillions": ("us", jackpots["us"].get("megaMillions")), + "lottoMax": ("canadian", jackpots["canadian"].get("lottoMax")), + "lotto649": ("canadian", jackpots["canadian"].get("lotto649")), } - }) + for key, (country_type, amount) in lottery_map.items(): + odds_info = LOTTERY_ODDS.get(key, {}) + entry = { + "key": key, + "name": odds_info.get("name", key), + "country": country_type, + "jackpot": amount, + "odds": odds_info.get("odds"), + "ticketCost": odds_info.get("ticket_cost"), + "oddsFormatted": f"1 in {odds_info.get('odds', 0):,}", + "calculation": None, + } -@app.route('/api/calculate', methods=['POST']) -def calculate(): - """API endpoint to calculate investment returns""" - data = request.json - - jackpot = data.get('jackpot') - lottery_type = data.get('type', 'us') # 'us' or 'canadian' - invest_percentage = data.get('investPercentage', 0.90) - annual_return = data.get('annualReturn', 0.045) - cycles = data.get('cycles', 8) - - if not jackpot: - return jsonify({"error": "Jackpot amount is required"}), 400 - - try: - if lottery_type == 'us': - result = calculate_us_lottery(jackpot, invest_percentage, annual_return, cycles) - else: - result = calculate_canadian_lottery(jackpot, invest_percentage, annual_return, cycles) - + if amount and amount > 0: + try: + if country_type == "us": + calc = calculate_us_lottery( + amount, + state_tax_rate=state_tax, + lump_sum_rate=cfg.tax.lump_sum_rate, + federal_tax_rate=cfg.tax.federal_tax_rate, + usd_cad_rate=cfg.tax.usd_cad_rate, + investment_income_tax_rate=cfg.tax.investment_income_tax_rate, + personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct, + ) + else: + calc = calculate_canadian_lottery( + amount, + investment_income_tax_rate=cfg.tax.investment_income_tax_rate, + personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct, + ) + entry["calculation"] = calc + except Exception: + logger.exception("Comparison calc failed for %s", key) + + comparisons.append(entry) + + return jsonify(comparisons) + + # ------------------------------------------------------------------ + # Break-even calculator + # ------------------------------------------------------------------ + + @app.route("/api/calculate/breakeven", methods=["POST"]) + def break_even(): + """Calculate the jackpot amount where expected value equals ticket cost.""" + data = _require_json() + if data is None: + return jsonify({"error": "Request body must be JSON"}), 400 + + lottery_key = data.get("lottery", "powerball") + odds_info = LOTTERY_ODDS.get(lottery_key) + if not odds_info: + return jsonify({"error": f"Unknown lottery: {lottery_key}"}), 400 + + ticket_cost = _validate_number( + data.get("ticketCost", odds_info["ticket_cost"]), + "ticketCost", + minimum=0.01, + ) + state_code = data.get("state") + state_tax = cfg.tax.default_state_tax_rate + if state_code: + st = STATE_TAX_RATES.get(state_code.upper()) + if st: + state_tax = st["rate"] + + result = calculate_break_even( + odds=odds_info["odds"], + ticket_cost=ticket_cost, + country=odds_info["country"], + lump_sum_rate=cfg.tax.lump_sum_rate, + federal_tax_rate=cfg.tax.federal_tax_rate, + state_tax_rate=state_tax, + ) + result["lottery"] = odds_info["name"] + result["oddsFormatted"] = f"1 in {odds_info['odds']:,}" return jsonify(result) - except Exception as e: - return jsonify({"error": str(e)}), 500 + + # ------------------------------------------------------------------ + # Annuity calculator + # ------------------------------------------------------------------ + + @app.route("/api/calculate/annuity", methods=["POST"]) + def annuity(): + """Calculate 30-year annuity payout schedule.""" + data = _require_json() + if data is None: + return jsonify({"error": "Request body must be JSON"}), 400 + + jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1) + if jackpot is None: + return jsonify({"error": "jackpot must be a positive number"}), 400 + + lottery_type = data.get("type", "us") + state_code = data.get("state") + state_tax = cfg.tax.default_state_tax_rate + if state_code: + st = STATE_TAX_RATES.get(state_code.upper()) + if st: + state_tax = st["rate"] + + years = int( + _validate_number( + data.get("years", ANNUITY_YEARS), "years", minimum=1, maximum=40 + ) + or ANNUITY_YEARS + ) + annual_increase = ( + _validate_number( + data.get("annualIncrease", ANNUITY_ANNUAL_INCREASE), + "annualIncrease", + minimum=0, + maximum=0.20, + ) + or ANNUITY_ANNUAL_INCREASE + ) + + result = calculate_annuity( + jackpot=jackpot, + country=lottery_type, + years=years, + annual_increase=annual_increase, + federal_tax_rate=cfg.tax.federal_tax_rate, + state_tax_rate=state_tax, + ) + return jsonify(result) + + # ------------------------------------------------------------------ + # Group play calculator + # ------------------------------------------------------------------ + + @app.route("/api/calculate/group", methods=["POST"]) + def group_play(): + """Split winnings among a group with optional custom shares.""" + data = _require_json() + if data is None: + return jsonify({"error": "Request body must be JSON"}), 400 + + jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1) + if jackpot is None: + return jsonify({"error": "jackpot must be a positive number"}), 400 + + members_val = _validate_number( + data.get("members", 2), "members", minimum=1, maximum=1000 + ) + members = int(members_val) if members_val else 2 + + shares = data.get("shares") # optional list of floats summing to 1.0 + lottery_type = data.get("type", "us") + + state_code = data.get("state") + state_tax = cfg.tax.default_state_tax_rate + if state_code: + st = STATE_TAX_RATES.get(state_code.upper()) + if st: + state_tax = st["rate"] + + result = calculate_group_split( + jackpot=jackpot, + members=members, + shares=shares, + country=lottery_type, + lump_sum_rate=cfg.tax.lump_sum_rate, + federal_tax_rate=cfg.tax.federal_tax_rate, + state_tax_rate=state_tax, + usd_cad_rate=cfg.tax.usd_cad_rate, + investment_income_tax_rate=cfg.tax.investment_income_tax_rate, + personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct, + ) + return jsonify(result) + + # ------------------------------------------------------------------ + # Odds / probability info + # ------------------------------------------------------------------ + + @app.route("/api/odds", methods=["GET"]) + def get_odds(): + """Return odds and ticket cost for all supported lotteries.""" + result = [] + for key, info in LOTTERY_ODDS.items(): + result.append( + { + "key": key, + "name": info["name"], + "odds": info["odds"], + "oddsFormatted": f"1 in {info['odds']:,}", + "oddsPercentage": f"{(1 / info['odds']) * 100:.10f}%", + "ticketCost": info["ticket_cost"], + "country": info["country"], + } + ) + return jsonify(result) + + # ------------------------------------------------------------------ + # Health check + # ------------------------------------------------------------------ + + @app.route("/api/health", methods=["GET"]) + def health(): + """Health check endpoint.""" + return jsonify({"status": "ok", "version": "2.0.0"}) + + return app -@app.route('/api/health', methods=['GET']) -def health(): - """Health check endpoint""" - return jsonify({"status": "ok"}) +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +app = create_app() - -if __name__ == '__main__': - print("🎰 Lottery Investment Calculator API") - print("=" * 50) - print("Starting Flask server on http://localhost:5000") - print("API Endpoints:") - print(" - GET /api/jackpots - Get current jackpots") - print(" - POST /api/calculate - Calculate investments") - print(" - GET /api/health - Health check") - print("=" * 50) - # Bind to 0.0.0.0 so the Flask app is reachable from outside the container - app.run(debug=True, host='0.0.0.0', port=5000) +if __name__ == "__main__": + cfg = load_config() + logger.info("Lottery Investment Calculator API v2.0") + logger.info("Endpoints:") + logger.info(" GET /api/jackpots - Current jackpots (cached)") + logger.info(" POST /api/jackpots/refresh - Force refresh") + logger.info(" POST /api/calculate - Investment calculator") + logger.info(" POST /api/calculate/breakeven - Break-even calculator") + logger.info(" POST /api/calculate/annuity - Annuity calculator") + logger.info(" POST /api/calculate/group - Group play calculator") + logger.info(" GET /api/compare - Side-by-side comparison") + logger.info(" GET /api/states - US state tax rates") + logger.info(" GET /api/odds - Lottery odds info") + logger.info(" GET /api/health - Health check") + app.run(debug=cfg.debug, host=cfg.host, port=cfg.port) diff --git a/config.py b/config.py new file mode 100644 index 0000000..9b33cc2 --- /dev/null +++ b/config.py @@ -0,0 +1,212 @@ +""" +Centralized configuration for the Lottery Investment Calculator. + +All magic numbers, URLs, tax rates, and tunable parameters live here. +Values are loaded from environment variables with sensible defaults. +""" + +import os +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- +# US State Tax Rates (2024-2025) +# --------------------------------------------------------------------------- +STATE_TAX_RATES: dict[str, dict] = { + "AL": {"name": "Alabama", "rate": 0.05}, + "AK": {"name": "Alaska", "rate": 0.0}, + "AZ": {"name": "Arizona", "rate": 0.025}, + "AR": {"name": "Arkansas", "rate": 0.044}, + "CA": {"name": "California", "rate": 0.133}, + "CO": {"name": "Colorado", "rate": 0.044}, + "CT": {"name": "Connecticut", "rate": 0.0699}, + "DE": {"name": "Delaware", "rate": 0.066}, + "FL": {"name": "Florida", "rate": 0.0}, + "GA": {"name": "Georgia", "rate": 0.055}, + "HI": {"name": "Hawaii", "rate": 0.11}, + "ID": {"name": "Idaho", "rate": 0.058}, + "IL": {"name": "Illinois", "rate": 0.0495}, + "IN": {"name": "Indiana", "rate": 0.0305}, + "IA": {"name": "Iowa", "rate": 0.06}, + "KS": {"name": "Kansas", "rate": 0.057}, + "KY": {"name": "Kentucky", "rate": 0.04}, + "LA": {"name": "Louisiana", "rate": 0.0425}, + "ME": {"name": "Maine", "rate": 0.0715}, + "MD": {"name": "Maryland", "rate": 0.0575}, + "MA": {"name": "Massachusetts", "rate": 0.09}, + "MI": {"name": "Michigan", "rate": 0.0425}, + "MN": {"name": "Minnesota", "rate": 0.0985}, + "MS": {"name": "Mississippi", "rate": 0.05}, + "MO": {"name": "Missouri", "rate": 0.048}, + "MT": {"name": "Montana", "rate": 0.059}, + "NE": {"name": "Nebraska", "rate": 0.0564}, + "NV": {"name": "Nevada", "rate": 0.0}, + "NH": {"name": "New Hampshire", "rate": 0.0}, + "NJ": {"name": "New Jersey", "rate": 0.1075}, + "NM": {"name": "New Mexico", "rate": 0.059}, + "NY": {"name": "New York", "rate": 0.109}, + "NC": {"name": "North Carolina", "rate": 0.045}, + "ND": {"name": "North Dakota", "rate": 0.0195}, + "OH": {"name": "Ohio", "rate": 0.0357}, + "OK": {"name": "Oklahoma", "rate": 0.0475}, + "OR": {"name": "Oregon", "rate": 0.099}, + "PA": {"name": "Pennsylvania", "rate": 0.0307}, + "RI": {"name": "Rhode Island", "rate": 0.0599}, + "SC": {"name": "South Carolina", "rate": 0.064}, + "SD": {"name": "South Dakota", "rate": 0.0}, + "TN": {"name": "Tennessee", "rate": 0.0}, + "TX": {"name": "Texas", "rate": 0.0}, + "UT": {"name": "Utah", "rate": 0.0465}, + "VT": {"name": "Vermont", "rate": 0.0875}, + "VA": {"name": "Virginia", "rate": 0.0575}, + "WA": {"name": "Washington", "rate": 0.0}, + "WV": {"name": "West Virginia", "rate": 0.055}, + "WI": {"name": "Wisconsin", "rate": 0.0765}, + "WY": {"name": "Wyoming", "rate": 0.0}, + "DC": {"name": "District of Columbia", "rate": 0.105}, +} + +# --------------------------------------------------------------------------- +# Lottery Odds (1 in N for jackpot) +# --------------------------------------------------------------------------- +LOTTERY_ODDS: dict[str, dict] = { + "powerball": { + "name": "Powerball", + "odds": 292_201_338, + "ticket_cost": 2.0, + "country": "us", + }, + "megaMillions": { + "name": "Mega Millions", + "odds": 302_575_350, + "ticket_cost": 2.0, + "country": "us", + }, + "lottoMax": { + "name": "Lotto Max", + "odds": 33_294_800, + "ticket_cost": 5.0, + "country": "canadian", + }, + "lotto649": { + "name": "Lotto 6/49", + "odds": 13_983_816, + "ticket_cost": 3.0, + "country": "canadian", + }, +} + +# Annuity constants +ANNUITY_YEARS = 30 +ANNUITY_ANNUAL_INCREASE = 0.05 # 5% annual increase (Powerball/MM standard) + + +def _env_float(key: str, default: float) -> float: + """Read a float from an environment variable with a fallback.""" + raw = os.environ.get(key) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def _env_str(key: str, default: str) -> str: + return os.environ.get(key, default) + + +def _env_int(key: str, default: int) -> int: + raw = os.environ.get(key) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_bool(key: str, default: bool) -> bool: + raw = os.environ.get(key, "").lower() + if raw in ("1", "true", "yes"): + return True + if raw in ("0", "false", "no"): + return False + return default + + +@dataclass(frozen=True) +class ScraperURLs: + """Target URLs for lottery scraping.""" + + powerball: str = "https://www.lotto.net/powerball" + mega_millions: str = "https://www.lotto.net/mega-millions" + olg: str = "https://www.olg.ca/" + + +@dataclass(frozen=True) +class TaxConfig: + """Tax and financial parameters.""" + + lump_sum_rate: float = 0.52 + federal_tax_rate: float = 0.37 + default_state_tax_rate: float = 0.055 + usd_cad_rate: float = 1.35 + investment_income_tax_rate: float = 0.5353 + personal_withdrawal_pct: float = 0.10 + + +@dataclass(frozen=True) +class InvestmentDefaults: + """Default investment calculation parameters.""" + + invest_percentage: float = 0.90 + annual_return: float = 0.045 + cycles: int = 8 + + +@dataclass(frozen=True) +class AppConfig: + """Top-level application configuration.""" + + # Flask + debug: bool = False + host: str = "0.0.0.0" + port: int = 5000 + allowed_origins: str = "*" + + # Sub-configs + urls: ScraperURLs = field(default_factory=ScraperURLs) + tax: TaxConfig = field(default_factory=TaxConfig) + investment: InvestmentDefaults = field(default_factory=InvestmentDefaults) + + # Scraper cache TTL in seconds (default 6 hours) + cache_ttl: int = 21600 + + +def load_config() -> AppConfig: + """Build an ``AppConfig`` from environment variables.""" + return AppConfig( + debug=_env_bool("FLASK_DEBUG", False), + host=_env_str("FLASK_HOST", "0.0.0.0"), + port=_env_int("FLASK_PORT", 5000), + allowed_origins=_env_str("ALLOWED_ORIGINS", "*"), + urls=ScraperURLs( + powerball=_env_str("SCRAPER_URL_POWERBALL", ScraperURLs.powerball), + mega_millions=_env_str("SCRAPER_URL_MEGA_MILLIONS", ScraperURLs.mega_millions), + olg=_env_str("SCRAPER_URL_OLG", ScraperURLs.olg), + ), + tax=TaxConfig( + lump_sum_rate=_env_float("LUMP_SUM_RATE", TaxConfig.lump_sum_rate), + federal_tax_rate=_env_float("FEDERAL_TAX_RATE", TaxConfig.federal_tax_rate), + default_state_tax_rate=_env_float("DEFAULT_STATE_TAX_RATE", TaxConfig.default_state_tax_rate), + usd_cad_rate=_env_float("USD_CAD_RATE", TaxConfig.usd_cad_rate), + investment_income_tax_rate=_env_float("INVESTMENT_INCOME_TAX_RATE", TaxConfig.investment_income_tax_rate), + personal_withdrawal_pct=_env_float("PERSONAL_WITHDRAWAL_PCT", TaxConfig.personal_withdrawal_pct), + ), + investment=InvestmentDefaults( + invest_percentage=_env_float("DEFAULT_INVEST_PCT", InvestmentDefaults.invest_percentage), + annual_return=_env_float("DEFAULT_ANNUAL_RETURN", InvestmentDefaults.annual_return), + cycles=_env_int("DEFAULT_CYCLES", InvestmentDefaults.cycles), + ), + cache_ttl=_env_int("CACHE_TTL_SECONDS", 21600), + ) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 29fe4b8..1c32944 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # Nginx Reverse Proxy nginx: @@ -26,14 +24,17 @@ services: container_name: lottery-backend expose: - "5000" + env_file: + - .env environment: - FLASK_ENV=production + - FLASK_DEBUG=false - PYTHONUNBUFFERED=1 restart: always networks: - lottery-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] interval: 30s timeout: 10s retries: 3 @@ -41,10 +42,10 @@ services: deploy: resources: limits: - cpus: '1' + cpus: "1" memory: 2G reservations: - cpus: '0.5' + cpus: "0.5" memory: 1G # Next.js Frontend @@ -52,11 +53,13 @@ services: build: context: . dockerfile: Dockerfile.frontend + args: + NEXT_PUBLIC_API_URL: /api container_name: lottery-frontend expose: - "3000" environment: - - NEXT_PUBLIC_API_URL=http://backend:5000 + - NEXT_PUBLIC_API_URL=/api - NODE_ENV=production depends_on: - backend @@ -66,29 +69,12 @@ services: deploy: resources: limits: - cpus: '0.5' + cpus: "0.5" memory: 512M reservations: - cpus: '0.25' + cpus: "0.25" memory: 256M - # Email Scheduler - email-scheduler: - build: - context: . - dockerfile: Dockerfile.email - container_name: lottery-email - environment: - - PYTHONUNBUFFERED=1 - restart: always - networks: - - lottery-network - deploy: - resources: - limits: - cpus: '0.5' - memory: 1G - networks: lottery-network: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 3033c76..7a54081 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,14 +9,17 @@ services: container_name: lottery-backend ports: - "5000:5000" + env_file: + - .env environment: - FLASK_ENV=production + - FLASK_DEBUG=false - PYTHONUNBUFFERED=1 restart: unless-stopped networks: - lottery-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] interval: 30s timeout: 10s retries: 3 @@ -40,20 +43,6 @@ services: networks: - lottery-network - # Email Scheduler (Optional - runs daily at 7 AM) - email-scheduler: - build: - context: . - dockerfile: Dockerfile.email - container_name: lottery-email - environment: - - PYTHONUNBUFFERED=1 - restart: unless-stopped - networks: - - lottery-network - profiles: - - email # Only start if explicitly requested - networks: lottery-network: driver: bridge diff --git a/email_sender.py b/email_sender.py deleted file mode 100644 index 94c72e4..0000000 --- a/email_sender.py +++ /dev/null @@ -1,329 +0,0 @@ -import schedule -import time -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from datetime import datetime -import asyncio -from playwright.async_api import async_playwright -import requests -from bs4 import BeautifulSoup -import urllib3 -import re - -# Disable SSL warnings -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -# Email configuration -EMAIL_CONFIG = { - 'smtp_server': 'smtp.gmail.com', # Change this for your email provider - 'smtp_port': 587, - 'sender_email': 'mblanke@gmail.com', # Replace with your email - 'sender_password': 'vyapvyjjfrqpqnax', # App password (spaces removed) - 'recipient_email': 'mblanke@gmail.com', # Replace with recipient email -} - -# Common headers to mimic a browser request -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", -} - -# Scraping functions -def get_powerball(): - """Get Powerball jackpot from lotto.net""" - try: - url = "https://www.lotto.net/powerball" - response = requests.get(url, timeout=10, verify=False, headers=HEADERS) - response.raise_for_status() - soup = BeautifulSoup(response.text, 'html.parser') - - # Look for "Next Jackpot" text - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - # Parse the amount - match = re.search(r'\$\s*([\d,]+(?:\.\d+)?)\s*Million', next_line) - if match: - amount_str = match.group(1).replace(',', '') - return float(amount_str) - except Exception as e: - print(f"Error getting Powerball: {e}") - return None - -def get_mega_millions(): - """Get Mega Millions jackpot from lotto.net""" - try: - url = "https://www.lotto.net/mega-millions" - response = requests.get(url, timeout=10, verify=False, headers=HEADERS) - response.raise_for_status() - soup = BeautifulSoup(response.text, 'html.parser') - - # Look for "Next Jackpot" text - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - # Parse the amount - match = re.search(r'\$\s*([\d,]+(?:\.\d+)?)\s*Million', next_line) - if match: - amount_str = match.group(1).replace(',', '') - return float(amount_str) - except Exception as e: - print(f"Error getting Mega Millions: {e}") - return None - -async def get_canadian_lotteries(): - """Get Lotto Max and Lotto 6/49 jackpots using Playwright""" - lotto_max = None - lotto_649 = None - - try: - async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) - page = await browser.new_page() - - await page.goto('https://www.olg.ca/', wait_until='networkidle') - await page.wait_for_timeout(2000) - - content = await page.content() - - # Lotto Max pattern - lotto_max_pattern = r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million' - match = re.search(lotto_max_pattern, content, re.IGNORECASE | re.DOTALL) - if match: - amount_str = match.group(1).replace(',', '') - lotto_max = float(amount_str) - - # Lotto 6/49 pattern - lotto_649_pattern = r'LOTTO\s*6/49.*?\$\s*([\d.,]+)\s*Million' - match = re.search(lotto_649_pattern, content, re.IGNORECASE | re.DOTALL) - if match: - amount_str = match.group(1).replace(',', '') - lotto_649 = float(amount_str) - - await browser.close() - except Exception as e: - print(f"Error getting Canadian lotteries: {e}") - - return lotto_max, lotto_649 - -def format_currency(amount): - """Format amount as currency""" - if amount is None: - return "Not available" - return f"${amount:,.0f}M" - -def create_email_html(powerball, mega_millions, lotto_max, lotto_649): - """Create HTML email content""" - html = f""" - - - - - -
-

🎰 Daily Lottery Jackpots

- -
-

πŸ‡ΊπŸ‡Έ US Lotteries

-
-
- Powerball -
- {format_currency(powerball)} -
-
-
- Mega Millions -
- {format_currency(mega_millions)} -
-
- -
-

πŸ‡¨πŸ‡¦ Canadian Lotteries

-
-
- Lotto Max - TAX FREE -
- {format_currency(lotto_max)} -
-
-
- Lotto 6/49 - TAX FREE -
- {format_currency(lotto_649)} -
-
- - - -
- Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')} -
-
- - - """ - return html - -def send_email(subject, html_content): - """Send email with jackpot information""" - try: - # Create message - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = EMAIL_CONFIG['sender_email'] - msg['To'] = EMAIL_CONFIG['recipient_email'] - - # Attach HTML content - html_part = MIMEText(html_content, 'html') - msg.attach(html_part) - - # Send email - with smtplib.SMTP(EMAIL_CONFIG['smtp_server'], EMAIL_CONFIG['smtp_port']) as server: - server.starttls() - server.login(EMAIL_CONFIG['sender_email'], EMAIL_CONFIG['sender_password']) - server.send_message(msg) - - print(f"βœ… Email sent successfully at {datetime.now().strftime('%I:%M %p')}") - return True - except Exception as e: - print(f"❌ Error sending email: {e}") - return False - -def send_daily_jackpots(): - """Fetch jackpots and send email""" - print(f"\n{'='*50}") - print(f"🎰 Fetching lottery jackpots at {datetime.now().strftime('%I:%M %p')}") - print(f"{'='*50}") - - # Get US lotteries - print("πŸ“Š Fetching Powerball...") - powerball = get_powerball() - print(f" Powerball: {format_currency(powerball)}") - - print("πŸ“Š Fetching Mega Millions...") - mega_millions = get_mega_millions() - print(f" Mega Millions: {format_currency(mega_millions)}") - - # Get Canadian lotteries - print("πŸ“Š Fetching Canadian lotteries...") - lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries()) - print(f" Lotto Max: {format_currency(lotto_max)}") - print(f" Lotto 6/49: {format_currency(lotto_649)}") - - # Create email content - subject = f"🎰 Daily Lottery Report - {datetime.now().strftime('%B %d, %Y')}" - html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649) - - # Send email - print("\nπŸ“§ Sending email...") - send_email(subject, html_content) - print(f"{'='*50}\n") - -def main(): - """Main function to schedule and run the email sender""" - print("πŸš€ Lottery Jackpot Email Scheduler Started") - print("=" * 50) - print(f"πŸ“§ Emails will be sent to: {EMAIL_CONFIG['recipient_email']}") - print(f"⏰ Scheduled time: 7:00 AM daily") - print(f"πŸ”„ Current time: {datetime.now().strftime('%I:%M %p')}") - print("=" * 50) - print("\nPress Ctrl+C to stop the scheduler\n") - - # Schedule the job for 7:00 AM every day - schedule.every().day.at("07:00").do(send_daily_jackpots) - - # Optional: Uncomment to send immediately for testing - # print("πŸ§ͺ Sending test email now...") - # send_daily_jackpots() - - # Keep the script running - while True: - schedule.run_pending() - time.sleep(60) # Check every minute - -if __name__ == "__main__": - main() diff --git a/import requests.py b/import requests.py deleted file mode 100644 index a30766d..0000000 --- a/import requests.py +++ /dev/null @@ -1,186 +0,0 @@ -import requests -from bs4 import BeautifulSoup -import urllib3 -from playwright.sync_api import sync_playwright -import re - -# Suppress SSL warnings -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -# Common headers to mimic a browser request -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Cache-Control": "max-age=0", -} - -def get_powerball(): - url = "https://www.lotto.net/powerball" - try: - resp = requests.get(url, timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - # Look for divs containing "Next Jackpot" and "$XXX Million" - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - return next_line - return "Not found" - except Exception as e: - return f"Error: {e}" - -def get_mega_millions(): - url = "https://www.lotto.net/mega-millions" - try: - resp = requests.get(url, timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - # Look for divs containing "Next Jackpot" and "$XXX Million" - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - return next_line - return "Not found" - except Exception as e: - return f"Error: {e}" - -def get_lotto_max(): - url = "https://www.olg.ca/" - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto(url, wait_until="networkidle", timeout=30000) - # Wait for lottery content to load - page.wait_for_timeout(3000) - content = page.content() - browser.close() - - # Search for Lotto Max jackpot - look for the pattern more carefully - # Match "LOTTO MAX" followed by jackpot info, avoiding 649 - match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if match: - return f"${match.group(1)} Million" - return "Not found" - except Exception as e: - return f"Error: {e}" - -def get_lotto_649(): - url = "https://www.olg.ca/" - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto(url, wait_until="networkidle", timeout=30000) - # Wait for lottery content to load - page.wait_for_timeout(3000) - content = page.content() - browser.close() - - # Search for Lotto 6/49 jackpot - be more specific - match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if match: - return f"${match.group(1)} Million" - return "Not found" - except Exception as e: - return f"Error: {e}" - -def get_olg_lotteries(): - """ - Fetches jackpot amounts for Lotto Max and Lotto 6/49 from OLG website using Playwright. - Returns a dict with keys 'Lotto Max' and 'Lotto 6/49'. - """ - url = "https://www.olg.ca/" - results = {"Lotto Max": "Not found", "Lotto 6/49": "Not found"} - try: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto(url, wait_until="networkidle", timeout=30000) - # Wait for lottery content to load - page.wait_for_timeout(3000) - content = page.content() - browser.close() - - # Lotto Max - be more specific to avoid 649 - lotto_max_match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if lotto_max_match: - results["Lotto Max"] = f"${lotto_max_match.group(1)} Million" - - # Lotto 6/49 - be more specific to avoid MAX - lotto_649_match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL) - if lotto_649_match: - results["Lotto 6/49"] = f"${lotto_649_match.group(1)} Million" - except Exception as e: - results = {"Lotto Max": f"Error: {e}", "Lotto 6/49": f"Error: {e}"} - return results - -def get_lottery_usa(): - """ - Fetches jackpot amounts for Powerball and Mega Millions from lotto.net. - Returns a dict with keys 'Powerball' and 'Mega Millions'. - """ - results = {"Powerball": "Not found", "Mega Millions": "Not found"} - - # Get Powerball - try: - resp = requests.get("https://www.lotto.net/powerball", timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - results["Powerball"] = next_line - break - except Exception as e: - results["Powerball"] = f"Error: {e}" - - # Get Mega Millions - try: - resp = requests.get("https://www.lotto.net/mega-millions", timeout=10, verify=False, headers=HEADERS) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - all_text = soup.get_text() - lines = all_text.split('\n') - for i, line in enumerate(lines): - if 'Next Jackpot' in line and i + 1 < len(lines): - next_line = lines[i + 1].strip() - if '$' in next_line and 'Million' in next_line: - results["Mega Millions"] = next_line - break - except Exception as e: - results["Mega Millions"] = f"Error: {e}" - - return results - -if __name__ == "__main__": - print("🎰 Current Lottery Jackpots") - print("------------------------------") - print(f"Powerball: {get_powerball()}") - print(f"Mega Millions: {get_mega_millions()}") - print(f"Lotto Max: {get_lotto_max()}") - print(f"Lotto 6/49: {get_lotto_649()}") - # Add OLG results as fallback/alternative - olg = get_olg_lotteries() - print(f"OLG Lotto Max: {olg['Lotto Max']}") - print(f"OLG Lotto 6/49: {olg['Lotto 6/49']}") - # Add Lottery USA results - lottery_usa = get_lottery_usa() - print(f"Lottery USA Powerball: {lottery_usa['Powerball']}") - print(f"Lottery USA Mega Millions: {lottery_usa['Mega Millions']}") diff --git a/lottery_calculator.py b/lottery_calculator.py index 4c0e67d..32f9a48 100644 --- a/lottery_calculator.py +++ b/lottery_calculator.py @@ -1,195 +1,344 @@ """ -Lottery Investment Calculator -Handles both US and Canadian lottery calculations +Lottery Investment Calculator β€” pure calculation logic. + +All functions are deterministic and side-effect free. +Tax rates, exchange rates, and investment defaults are passed as explicit +parameters (with sensible defaults) so that callers can override via config. """ -def calculate_us_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8): +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Core calculators +# --------------------------------------------------------------------------- + +def _run_investment_cycles( + principal: float, + annual_return: float, + cycles: int, + investment_income_tax_rate: float, + personal_withdrawal_pct: float, +) -> tuple[list[dict], float, float]: + """Simulate 90-day reinvestment cycles. + + Returns: + (cycle_results, total_personal_withdrawals, final_principal) """ - Calculate investment returns for US lottery winnings - + cycle_results: list[dict] = [] + total_personal_withdrawals = 0.0 + + for cycle in range(1, cycles + 1): + interest_earned = principal * annual_return * (90 / 365) + taxes_owed = interest_earned * investment_income_tax_rate + personal_withdrawal = interest_earned * personal_withdrawal_pct + total_withdrawal = taxes_owed + personal_withdrawal + reinvestment = interest_earned - total_withdrawal + new_principal = principal + reinvestment + + total_personal_withdrawals += personal_withdrawal + + cycle_results.append( + { + "cycle": cycle, + "principalStart": principal, + "interestEarned": interest_earned, + "taxesOwed": taxes_owed, + "personalWithdrawal": personal_withdrawal, + "totalWithdrawal": total_withdrawal, + "reinvestment": reinvestment, + "principalEnd": new_principal, + } + ) + principal = new_principal + + return cycle_results, total_personal_withdrawals, principal + + +def calculate_us_lottery( + jackpot: float, + invest_percentage: float = 0.90, + annual_return: float = 0.045, + cycles: int = 8, + *, + state_tax_rate: float = 0.055, + lump_sum_rate: float = 0.52, + federal_tax_rate: float = 0.37, + usd_cad_rate: float = 1.35, + investment_income_tax_rate: float = 0.5353, + personal_withdrawal_pct: float = 0.10, +) -> dict: + """Calculate investment returns for US lottery winnings. + Args: - jackpot: Original jackpot amount (USD) - invest_percentage: Percentage to invest (default 90%) - annual_return: Annual return rate (default 4.5%) - cycles: Number of 90-day cycles to calculate (default 8) + jackpot: Advertised jackpot amount (USD). + invest_percentage: Fraction to invest (0-1). + annual_return: Expected annual return rate. + cycles: Number of 90-day reinvestment cycles. + state_tax_rate: State income-tax rate on winnings. + lump_sum_rate: Fraction of advertised jackpot available as lump sum. + federal_tax_rate: Federal income-tax rate on winnings. + usd_cad_rate: USD to CAD exchange rate. + investment_income_tax_rate: Marginal tax on investment income. + personal_withdrawal_pct: Fraction of interest withdrawn each cycle. """ - # US Lottery calculations - cash_sum = jackpot * 0.52 # Lump sum is 52% - federal_tax = cash_sum * 0.37 - state_tax = cash_sum * 0.055 + cash_sum = jackpot * lump_sum_rate + federal_tax = cash_sum * federal_tax_rate + state_tax = cash_sum * state_tax_rate net_amount = cash_sum - federal_tax - state_tax - - # Convert to Canadian dollars - canadian_amount = net_amount * 1.35 - - # Split into investment and fun money + + canadian_amount = net_amount * usd_cad_rate + investment_principal = canadian_amount * invest_percentage fun_money = canadian_amount * (1 - invest_percentage) - - # Calculate cycles - cycle_results = [] - principal = investment_principal - total_personal_withdrawals = 0 - - for cycle in range(1, cycles + 1): - # Interest for 90 days - interest_earned = principal * annual_return * (90/365) - - # Taxes on investment income (53.53%) - taxes_owed = interest_earned * 0.5353 - - # Personal withdrawal (10% of interest) - personal_withdrawal = interest_earned * 0.10 - - # Total withdrawal - total_withdrawal = taxes_owed + personal_withdrawal - - # Reinvestment - reinvestment = interest_earned - total_withdrawal - - # New principal - new_principal = principal + reinvestment - - total_personal_withdrawals += personal_withdrawal - - cycle_results.append({ - 'cycle': cycle, - 'principal_start': principal, - 'interest_earned': interest_earned, - 'taxes_owed': taxes_owed, - 'personal_withdrawal': personal_withdrawal, - 'total_withdrawal': total_withdrawal, - 'reinvestment': reinvestment, - 'principal_end': new_principal - }) - - principal = new_principal - - # Calculate daily income - net_daily_income = (investment_principal * annual_return * 0.5353) / 365 - + + cycle_results, total_withdrawals, final_principal = _run_investment_cycles( + investment_principal, + annual_return, + cycles, + investment_income_tax_rate, + personal_withdrawal_pct, + ) + + net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365 + return { - 'country': 'US', - 'original_jackpot': jackpot, - 'cash_sum': cash_sum, - 'federal_tax': federal_tax, - 'state_tax': state_tax, - 'net_amount_usd': net_amount, - 'net_amount_cad': canadian_amount, - 'investment_principal': investment_principal, - 'fun_money': fun_money, - 'net_daily_income': net_daily_income, - 'annual_income': net_daily_income * 365, - 'total_personal_withdrawals': total_personal_withdrawals, - 'final_principal': principal, - 'cycles': cycle_results + "country": "US", + "originalJackpot": jackpot, + "cashSum": cash_sum, + "federalTax": federal_tax, + "stateTax": state_tax, + "stateTaxRate": state_tax_rate, + "netAmountUsd": net_amount, + "netAmountCad": canadian_amount, + "investmentPrincipal": investment_principal, + "funMoney": fun_money, + "netDailyIncome": net_daily_income, + "annualIncome": net_daily_income * 365, + "totalPersonalWithdrawals": total_withdrawals, + "finalPrincipal": final_principal, + "cycles": cycle_results, } -def calculate_canadian_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8): - """ - Calculate investment returns for Canadian lottery winnings - +def calculate_canadian_lottery( + jackpot: float, + invest_percentage: float = 0.90, + annual_return: float = 0.045, + cycles: int = 8, + *, + investment_income_tax_rate: float = 0.5353, + personal_withdrawal_pct: float = 0.10, +) -> dict: + """Calculate investment returns for Canadian lottery winnings (tax-free). + Args: - jackpot: Original jackpot amount (CAD) - TAX FREE! - invest_percentage: Percentage to invest (default 90%) - annual_return: Annual return rate (default 4.5%) - cycles: Number of 90-day cycles to calculate (default 8) + jackpot: Jackpot amount (CAD) - no tax deducted on winnings. + invest_percentage: Fraction to invest (0-1). + annual_return: Expected annual return rate. + cycles: Number of 90-day reinvestment cycles. + investment_income_tax_rate: Marginal tax on investment income. + personal_withdrawal_pct: Fraction of interest withdrawn each cycle. """ - # Canadian lotteries - NO TAX on winnings! - net_amount = jackpot - - # Split into investment and fun money + net_amount = jackpot # Tax-free! + investment_principal = net_amount * invest_percentage fun_money = net_amount * (1 - invest_percentage) - - # Calculate cycles - cycle_results = [] - principal = investment_principal - total_personal_withdrawals = 0 - - for cycle in range(1, cycles + 1): - # Interest for 90 days - interest_earned = principal * annual_return * (90/365) - - # Taxes on investment income (53.53%) - taxes_owed = interest_earned * 0.5353 - - # Personal withdrawal (10% of interest) - personal_withdrawal = interest_earned * 0.10 - - # Total withdrawal - total_withdrawal = taxes_owed + personal_withdrawal - - # Reinvestment - reinvestment = interest_earned - total_withdrawal - - # New principal - new_principal = principal + reinvestment - - total_personal_withdrawals += personal_withdrawal - - cycle_results.append({ - 'cycle': cycle, - 'principal_start': principal, - 'interest_earned': interest_earned, - 'taxes_owed': taxes_owed, - 'personal_withdrawal': personal_withdrawal, - 'total_withdrawal': total_withdrawal, - 'reinvestment': reinvestment, - 'principal_end': new_principal - }) - - principal = new_principal - - # Calculate daily income - net_daily_income = (investment_principal * annual_return * 0.5353) / 365 - + + cycle_results, total_withdrawals, final_principal = _run_investment_cycles( + investment_principal, + annual_return, + cycles, + investment_income_tax_rate, + personal_withdrawal_pct, + ) + + net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365 + return { - 'country': 'Canada', - 'original_jackpot': jackpot, - 'net_amount_cad': net_amount, - 'investment_principal': investment_principal, - 'fun_money': fun_money, - 'net_daily_income': net_daily_income, - 'annual_income': net_daily_income * 365, - 'total_personal_withdrawals': total_personal_withdrawals, - 'final_principal': principal, - 'cycles': cycle_results + "country": "Canada", + "originalJackpot": jackpot, + "netAmountCad": net_amount, + "investmentPrincipal": investment_principal, + "funMoney": fun_money, + "netDailyIncome": net_daily_income, + "annualIncome": net_daily_income * 365, + "totalPersonalWithdrawals": total_withdrawals, + "finalPrincipal": final_principal, + "cycles": cycle_results, } -if __name__ == "__main__": - # Test with current jackpots - print("=" * 80) - print("US LOTTERY - MEGA MILLIONS ($547M)") - print("=" * 80) - us_result = calculate_us_lottery(547_000_000) - print(f"Original Jackpot: ${us_result['original_jackpot']:,.0f}") - print(f"Cash Sum (52%): ${us_result['cash_sum']:,.0f}") - print(f"After Taxes (USD): ${us_result['net_amount_usd']:,.0f}") - print(f"After Taxes (CAD): ${us_result['net_amount_cad']:,.0f}") - print(f"Investment (90%): ${us_result['investment_principal']:,.0f}") - print(f"Fun Money (10%): ${us_result['fun_money']:,.0f}") - print(f"Daily Income: ${us_result['net_daily_income']:,.2f}") - print(f"Annual Income: ${us_result['annual_income']:,.2f}") - print(f"Final Principal (after 8 cycles): ${us_result['final_principal']:,.0f}") - - print("\n" + "=" * 80) - print("CANADIAN LOTTERY - LOTTO 6/49 ($32M CAD)") - print("=" * 80) - can_result = calculate_canadian_lottery(32_000_000) - print(f"Original Jackpot (TAX FREE!): ${can_result['original_jackpot']:,.0f}") - print(f"Investment (90%): ${can_result['investment_principal']:,.0f}") - print(f"Fun Money (10%): ${can_result['fun_money']:,.0f}") - print(f"Daily Income: ${can_result['net_daily_income']:,.2f}") - print(f"Annual Income: ${can_result['annual_income']:,.2f}") - print(f"Final Principal (after 8 cycles): ${can_result['final_principal']:,.0f}") - - print("\n" + "=" * 80) - print("COMPARISON") - print("=" * 80) - print(f"US ($547M) - You keep: ${us_result['net_amount_cad']:,.0f} CAD after taxes") - print(f"Canadian ($32M) - You keep: ${can_result['net_amount_cad']:,.0f} CAD (NO TAXES!)") - print(f"\nUS Daily Income: ${us_result['net_daily_income']:,.2f}") - print(f"Canadian Daily Income: ${can_result['net_daily_income']:,.2f}") +# --------------------------------------------------------------------------- +# Break-even calculator +# --------------------------------------------------------------------------- + +def calculate_break_even( + odds: int, + ticket_cost: float, + country: str = "us", + *, + lump_sum_rate: float = 0.52, + federal_tax_rate: float = 0.37, + state_tax_rate: float = 0.055, +) -> dict: + """Calculate the jackpot where expected value >= ticket cost. + + For US lotteries the take-home fraction is:: + + lump_sum_rate * (1 - federal_tax_rate - state_tax_rate) + + For Canadian lotteries the full jackpot is kept (tax-free). + + Returns a dict with the break-even jackpot and supporting details. + """ + if country == "us": + take_home_fraction = lump_sum_rate * (1 - federal_tax_rate - state_tax_rate) + else: + take_home_fraction = 1.0 + + # EV = (jackpot * take_home_fraction) / odds >= ticket_cost + # => jackpot >= ticket_cost * odds / take_home_fraction + break_even_jackpot = (ticket_cost * odds) / take_home_fraction + probability = 1 / odds + + return { + "breakEvenJackpot": break_even_jackpot, + "takeHomeFraction": take_home_fraction, + "odds": odds, + "probability": probability, + "ticketCost": ticket_cost, + "expectedValueAtBreakEven": probability * break_even_jackpot * take_home_fraction, + } + + +# --------------------------------------------------------------------------- +# Annuity calculator +# --------------------------------------------------------------------------- + +def calculate_annuity( + jackpot: float, + country: str = "us", + years: int = 30, + annual_increase: float = 0.05, + *, + federal_tax_rate: float = 0.37, + state_tax_rate: float = 0.055, +) -> dict: + """Calculate a multi-year annuity payout schedule. + + Powerball / Mega Millions annuities pay an initial amount then increase + each year by *annual_increase* (typically 5%). + + Returns yearly pre-tax and after-tax amounts plus totals. + """ + # Calculate initial annual payment using geometric series: + # jackpot = payment * sum((1 + r)^k) for k=0..years-1 + # = payment * ((1+r)^years - 1) / r + if annual_increase > 0: + geo_sum = ((1 + annual_increase) ** years - 1) / annual_increase + else: + geo_sum = float(years) + + initial_payment = jackpot / geo_sum + + schedule: list[dict] = [] + total_pre_tax = 0.0 + total_after_tax = 0.0 + + for year in range(1, years + 1): + pre_tax = initial_payment * (1 + annual_increase) ** (year - 1) + tax = pre_tax * (federal_tax_rate + state_tax_rate) if country == "us" else 0.0 + after_tax = pre_tax - tax + total_pre_tax += pre_tax + total_after_tax += after_tax + + schedule.append( + { + "year": year, + "preTax": pre_tax, + "tax": tax, + "afterTax": after_tax, + } + ) + + return { + "jackpot": jackpot, + "country": country, + "years": years, + "annualIncrease": annual_increase, + "initialPayment": initial_payment, + "totalPreTax": total_pre_tax, + "totalAfterTax": total_after_tax, + "schedule": schedule, + } + + +# --------------------------------------------------------------------------- +# Group play calculator +# --------------------------------------------------------------------------- + +def calculate_group_split( + jackpot: float, + members: int = 2, + shares: list[float] | None = None, + country: str = "us", + *, + lump_sum_rate: float = 0.52, + federal_tax_rate: float = 0.37, + state_tax_rate: float = 0.055, + usd_cad_rate: float = 1.35, + investment_income_tax_rate: float = 0.5353, + personal_withdrawal_pct: float = 0.10, +) -> dict: + """Split lottery winnings among *members* with optional custom shares. + + If *shares* is None every member gets an equal share. Otherwise *shares* + must be a list of floats summing to ~1.0 with length == *members*. + """ + # Normalise shares + if shares is None: + share_list = [1.0 / members] * members + else: + if len(shares) != members: + share_list = [1.0 / members] * members + else: + total = sum(shares) + share_list = [s / total for s in shares] if total > 0 else [1.0 / members] * members + + member_results: list[dict] = [] + + for i, share in enumerate(share_list): + member_jackpot = jackpot * share + if country == "us": + calc = calculate_us_lottery( + member_jackpot, + lump_sum_rate=lump_sum_rate, + federal_tax_rate=federal_tax_rate, + state_tax_rate=state_tax_rate, + usd_cad_rate=usd_cad_rate, + investment_income_tax_rate=investment_income_tax_rate, + personal_withdrawal_pct=personal_withdrawal_pct, + ) + else: + calc = calculate_canadian_lottery( + member_jackpot, + investment_income_tax_rate=investment_income_tax_rate, + personal_withdrawal_pct=personal_withdrawal_pct, + ) + + member_results.append( + { + "member": i + 1, + "share": share, + "jackpotShare": member_jackpot, + "calculation": calc, + } + ) + + return { + "originalJackpot": jackpot, + "members": members, + "shares": share_list, + "country": country, + "memberResults": member_results, + } diff --git a/megamillions_debug.html b/megamillions_debug.html deleted file mode 100644 index 3eec7937343fa74d47ffe1f14a83410f268d7050..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14338 zcmai52Xmd(nO5&ty-QcUOP1BUtls-oFIT<#syC~P#6SqiQcPJAmcT+NAz-h80Lcb2 zW3yl|&BkCmZj;&UWS8u~?Cu}f=eh6kcZJF9&OC2DUpeP(pODO$RnMqZ>9>D)2bckE z8bt@+-P6=j!{}stk(kvw$nM&mwXwLHZw-m>{HYNrpZ&tLBKG(B23!ecV0E=%!E(V4 zL`6zz7(_kD&Iv4;>wv8Y@Nv+rX3eg1Tg}B_E%D-AZs608rGu?rd=&i8uRkCwr770S z(3btotYLenHx*j?C|DmzLrqnK-c_s|&8aD|(7Hi2;>mVRn)T2YKYdJ7buM*k*L7hF znzyi71*NEF49+T$Uo69wu{`e_*9OHBXBfand9Ns*C6KG!GIu-L4yFRJpkD}w1!U}t zj7&>0Kr_dr4oalBZM%w@BZk+({wVm+Xk_-LtbzL<;dOkCz$oIg6OhB8PMwPz#LRF5 zcr{2vw6UiPhR<9>$GJy~#T^KtbMa!DSxW=G!QH&)!v~V~d-D%Ny90T5*f1}>runWs zRzIg1P`-R6wxlH43)-hIYW_rMIwnZq7v9+l?b?H2+ybBiA;$N4qqz8p`~{UY>lA;O znZqX5)rt)mLq7-#_HhgS{E^YCKZPFR+Rs{b%>@er0V*eA+`(7HX1Xj1kK-FN4_PzF zKx4d8pz9oqt@qkYvH%`ZKb*vHucmLsJDIy#wtz*v0nQPTNRXok|9lHeF&OF zxHVbYO#$*eInE~KXbX^3*gjh-|6zkk$93Y3pP^XPyFp@do&aqKBORhfU)H`DH`QxfS z-~sF+-v5DU!V{gl3(C5_c$P8#o4E1Z_uXe-fsbK`jXQq|LLGDJ7Rxaznd2%8w91MC zTIx8Qro;gE)-)))pH|A=fzdF#d|uvGTGn%@(jaOXAibuGy3IE z<0=`#j?%43h;k56lQY350A-6{#>+qdfK_A9fpa7dtgG_Vdm#Y6f=|z2Y3%g`gZm|A zg0tm#44{%T!i$a$P}lX7T_KPFlh2I+ogyxs7CFQejDU`a%^SGyB{73koy?`JSz5t0 zKo(=HA$@#$#0q4#f#5RM3!vv7gY?eKHV_7S?{2V)+zkT+Unsa(5QRrtj zaJ|o%I*r&>5IUEg7Wo^zoaJC?m=m{|3IHE7@b zv(ers??P7v3(X6}8zK|HH1L~I$!8wxQAMnS(XccqSM>4X%379!22atpMd;F9KY3o% zn42%fK=T1j$5hpG0@R;jWA1H4cpS6}Rva{&2nCV>xu>J1@ka4eNsGqQjs zbR3|075{bmowsF$rAilQi6;dXTVai&TqSQFD#BS7F{*3bqi$a@>Tf-1y%HLDF_7YrgEVV#6#>bDtsA# zRsneNS0_Xd8yFL1D4NF+l!#!qfn2|4XNbkQ(G+(+KPf02oJRrzL@dVoIx*5!(ai;& zx>TcjsOPz7M*2gT?fm#UZYN}=tB@xK7Fh^|{p=v1-6*nO#2tJEHjf9`kGW}!fBcQ8 z9yuaSOOWi%b+JErZCR9_RLC@@9LUU7Q4;gtpZ>il-~(rw;T0WwkGB97TO1glJfcPu zbd}i44?^$Zcqj%fGWmuHun5pW^bc(`tQ~`AsoLS_7Hcc(>RbAo56dL~nH$KVU0E*+la`FOs1Pu(bhW~!~zTX*Pl{KjLHyV64nb#tBuvVvW%P3>Zrh!t` zE}#M`=@%b^&PTAn)a)F}zVIq6AhvhNYj9mX%1MV6RluVm$A?m&`*6db6x>Uk4NI&DJp2gRB=r@RQ@cl?$$O*~{X|4uOEU zu6zT_Es`lHNGkS)81~^I+;_o%DiDLZ6u>;rCxZd$Hq6cfkHLu{U^@U?A<0#>t`$)_ z7ei$_n<3LGjC-$A^l8e=ksZ{UJCUr;5Rc9UZqP&VbH^)|G5Chx%omd)xmxyp)KgqJZ^3UP{=WI%!-!9U*s2=A_8K@mlOAeS|)=^WZ z>YGGgWTtug*dp>B<|ehi!JeOP)-pSjH339%jhVjvU4R;6>;>1*3N3Xp*Bo-q$?WZE zR?==h3*8B{+|Hd3G2FHE@$Mw=kM2o_JP6Mml@T6q7}T1k%C;l>;C0`%WW|RMV;8#C ze)RUiix29m4Aexy3j2r+XQ^qqe4Z_Cu6Ri91SntZ`|Ne@Cr)hjDUX=Qh*hWFYaeE8 zffaTUFc1P3Gw2-p{;M}m_wk|QAiv_El%tTqhxdsDAKDi+Wp6DK)Z5TJP~1SS98d!G;~m&#W6gg14(p4@Vh=Tk>~5;)Y^A{d>l5$Hg{orX zj_Ya){&e!?Eok~9>FX)ZU)>KCxb#jcw;9c%ZUeb;-1;@Ovl)46Po|u=RZnz*10^gB zmWA5m_1~Dd$BQq39Yp1-Sl_&T3QFqjiengo$|jMC5J~Rgv10Ar{0AtP9stYY$&$o9 z(hhn4r-yyEc{JX_ciVwd@{KHBfp4Xzic*p28cy^yu&% zHK?d(^~T$9TRQD}7HefUzaXlFoLH&bvvL8?1x!1GB~EUGPqJ!`=7BMusr_IAZ$5W3 z9<2W`7&|W?1n_GMG;m95Ml_16vQQ-7#yaC>03J{<&Q1A(N>>?kL7pun{#&t`wE_f- z;2ZshtiJdSS1D`$zcH?K6Vx4~_ijU*nMW9$_q?KjJ z#~cN5gc%)Xq96bp5iF$Y#X=P{_Q+&E6U!5;O+pjo>q4MRf*8(&*x>zkLtq65-CzAe zEU#S1aL}=0rfAMv+~6B)Jp;V$AU0v}1Q(BsmsP(Q($8KjxgCP3^>lttH?O~|3SPxu z?ijf7?rz7P=)!Xj8lt!Gfq;rf#jbWqt6o%<9T*HDuveVI@zV)X>4(Oc6@%`;(6kpk zX-nefZ5rI5S?;&Y)r#f+xa&#A=F%%*iwvbNt8y8X4g!2cfU{D8P|X2_)l~^P^LOIC z^xS?@q3X3_HZF@lLj`7BuF<&nI#20T2WFe6bMey_Mn&VKG%y#;bqg~Ja2KjfrZR62 z1X&6b0Y05=Y@|rxpa3`tSde`z24K;FKOWqH-5jHM1|e?#eZVHWAj;f{{o18G%94sn z2z0)nOb5my?QWk2FK8M87g&dU@RDe(F98c(kR)QHV{KpUeRlfre5$stDYxE&N`Iv0 z(iibtZ?m;>=||%0fEcg(PTBdx#So8G$t89o_9t1wiT!(nzeSr2*floba(j|t3v@*sF}nc6W&m@nVArQT<+Qrdk8&Y-Amhl&VQ8#)(0 z!qt-U+ik{|+yQD?tm=xd@3|jBHLmgUDV`Q!p$Zt8VJAD5%8`~)XU3rq z&>?mF4mQ^mzkJ?VTQ>8^zl;-9aH5{ z4uWGQRvbH;->@9SldyK0ybg=oXPP6EPYmSRwCc^W!Nbgp6Ue)$4cyM2%jV;SB$}Xh z^CPehO33pC2O7Wlu_z-DoPTVOMQ+#;bYzI`zln|wPxx(ELOjsqjc1uBKz}G`8^U{d zzSaC+k+B{d_*)_8!MWqAFt@sQgM)}ZT8fdL$q zY#TndF#$}_?=A8upC@23)yA^Q?m#_%kPsMxAo~jg#j<0^o%u&G7zGWJ!4nqy#sgU& zK6K>?EY^Z}l;Ug8`_W&QBoQd~Mfx<%dWrs8{ z&0AzZR>{((6ROx@Hi>tW#Uw}*VSno}%{3b|dhzGnT?q34^cprU-4hCBR&4uV?jbq6 zM$BCi;5)rwRz+ej$E$Q%s=gE}{vgAED|lpTRp`}wNbD2P3vo+4h}n7}CO zQffWT^~~!8pTCw}$}eVnRfCov5;wTXqc*F2cS}W&>E;m8a6~lbQp1tYezsZ&xHsEft0 zwi7v5-hC*+GsP`Zr&^BX&gThC5UstdL(@*&zwY*DzS)6Uud=J&m&+K2L{R!+zAi_c zhQvrwz`F{v!6RCotWCb^Yr~xinleOQ{4FFWb?;~ax3C#3e&O2?S<}ak<}9v=qt9Ji zjwsp333KCfPh}w2QhNLq1+^^oSQiaOS|Y?F5f>j zHGjyK8wnpS*A%p1|ikw2^#Az6r^E&2Wq270OQVT0!I1V!7N>Zd7iLZqEbG4scr)bTe8&=42a{m z0$5~59o6ao^4hPnnWiJWl35*Y+G~UmEFESuvxapp>=!^a`BWD4Fy_U!5Nw3QLe)An zAhj5*T6De;;P+c#+T!)h&m1T#vSXT+3RJQfZv7Kj{am{}gsY$W3_xcV1uEfju=3l% z#$$_A8Z6fg$))qEpBS&OrDAd3!E0LKNOCV>V@}Ijqi5!L>&-XV)dO^`!M1FX-+nSfmg)uYbU^)Q-22eB+hDTFh~ z{BJYd?JqoGVoZuEE61%MF@SQ`aySuK6P7M>XC76n=v;cZCyq8=2(T;!rhxZOMt;Se z@f}UGOG}I%<5|-IwrT#4TbmDG&4B@2dkjn``kDygMY{MN(bRYJ>Ty;#FD=Ulb7KIL zLH^yY^AG@wJTe!opT*b1ML-FUMKRSw{r4CK9vCPO7C8)|S_m2s%T58ju01Y_j|isL zz1N+$8O7t52N}(@1c6w9nV<#`!0m7w)6D#)JA?Yci)r9MXDC?O9#}+mxVXSq%)ddu z_~>HG7OX60u2vRDQjNAjk^6^W)klh}27*uKrJf)UkomXl^);+2c7>og$4ySDn!wEee2yiuf0V6PwG*Uu zm@(GY-^fD%Jxz>go;Mg9hs6M}^-b^ng5hFgt9+*V{?FsTeUi`Az?8+zfa&W$c-Oa% zb?sDqEXGmi;=mB3fcC$+=Lf5hLAiYss!WW9pJHes$6%l-BmUKlg{X}q<~Qo3nDldfPN9+ zUp)`DteGLAWfYDrFxtS_HW-WPKc9FrJC-kI{_GdFtRYAMlL$mssk1@PYzO1C($ZafN6muwUpd^jHL%spx5{)O7X z+f*fm>2q6Pg;asH=v=DwPS=0J&;@})2#za0|J;Ap<|zn^T8FYv@h-!cRp{@bK}`8L+fNObuuSc9@aVBn|9$yHMnQgGB}VB zq#|4528#h24ezrw3T$7Nj@yOj%oSw5hsRN69Ha)l7hFaVlQ57wmkH4aN?2ìG; zL3{S9ppeB|74h+X5L6Sq#Sfn205)xjA1pqcPLQvt!bfXUhM@(IuZ!)>CXE2)fTs6a zoA~O zxJ};?(^Ti`a9W641?yTvnrPjJS~@ij}?pByc+Ax-K2}DE=}wG z`o0tJ7(vWBzyoqy0nyDdDoP9vz$k>Nl+KpJ=<6E`KUxN5m~0g!NI%S6{P9Ny@IX~- z13??I71UpT zv<2lCaJ&W$1oMEbQ-FE)?*L=60rie)nn&40tUp0rWUnYoA~@hI?FfTUcak-5)8&^r zx4S<)`Q8JNrOUF?XB~0lO+G!G1uX?Tr`SUW}ML9Ln;z`UkKKo_}vgRG2%=p2`IVt_MB*p VLAi>wc-ZrFRykQY;Wvf0{|gmGGpYap diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..2dbbd78 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,77 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:5000; + } + + upstream frontend { + server frontend:3000; + } + + # Rate limiting zone + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + + server { + listen 80; + server_name _; + + # Redirect HTTP to HTTPS (uncomment when SSL is configured) + # return 301 https://$host$request_uri; + + # API reverse proxy + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Frontend + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache static assets + location /_next/static/ { + proxy_pass http://frontend; + proxy_cache_valid 200 365d; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + } + + # HTTPS server (uncomment when SSL certificates are in ./ssl/) + # server { + # listen 443 ssl; + # server_name your-domain.com; + # + # ssl_certificate /etc/nginx/ssl/cert.pem; + # ssl_certificate_key /etc/nginx/ssl/key.pem; + # + # location /api/ { + # limit_req zone=api burst=20 nodelay; + # proxy_pass http://backend; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # } + # + # location / { + # proxy_pass http://frontend; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # } + # } +} diff --git a/powerball_numbers.html b/powerball_numbers.html deleted file mode 100644 index 36205db..0000000 --- a/powerball_numbers.html +++ /dev/null @@ -1,5 +0,0 @@ -[ - { - "data": "\n\n\n\n\n \n\n\nPrevious Results | Powerball\n\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n\n\n \n \n\n \n\n\n\n\n\n\nSkip to content.\n\n\n\n\n
\n
\n

Previous Results

\n

Are you holding a winning ticket?

\n\n\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n\n
\n \n
\n \n
\n
\n
\n
\n
Sat, Jul 19, 2025
\n
\n
\n\n
\n
\n
\n\n
28
\n\n
48
\n\n
51
\n\n
61
\n\n
69
\n\n
20
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jul 16, 2025
\n
\n
\n\n
\n
\n
\n\n
4
\n\n
21
\n\n
43
\n\n
48
\n\n
49
\n\n
22
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jul 14, 2025
\n
\n
\n\n
\n
\n
\n\n
8
\n\n
12
\n\n
45
\n\n
46
\n\n
63
\n\n
24
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jul 12, 2025
\n
\n
\n\n
\n
\n
\n\n
8
\n\n
16
\n\n
24
\n\n
33
\n\n
54
\n\n
18
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jul 9, 2025
\n
\n
\n\n
\n
\n
\n\n
5
\n\n
9
\n\n
25
\n\n
28
\n\n
69
\n\n
5
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jul 7, 2025
\n
\n
\n\n
\n
\n
\n\n
33
\n\n
35
\n\n
58
\n\n
61
\n\n
69
\n\n
25
\n
\n
\n\n \n Power Play\n 5x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jul 5, 2025
\n
\n
\n\n
\n
\n
\n\n
1
\n\n
28
\n\n
34
\n\n
50
\n\n
58
\n\n
8
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jul 2, 2025
\n
\n
\n\n
\n
\n
\n\n
7
\n\n
19
\n\n
21
\n\n
54
\n\n
63
\n\n
21
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jun 30, 2025
\n
\n
\n\n
\n
\n
\n\n
13
\n\n
28
\n\n
44
\n\n
52
\n\n
55
\n\n
6
\n
\n
\n\n \n Power Play\n 4x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jun 28, 2025
\n
\n
\n\n
\n
\n
\n\n
4
\n\n
35
\n\n
43
\n\n
52
\n\n
62
\n\n
12
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jun 25, 2025
\n
\n
\n\n
\n
\n
\n\n
2
\n\n
12
\n\n
37
\n\n
51
\n\n
61
\n\n
22
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jun 23, 2025
\n
\n
\n\n
\n
\n
\n\n
5
\n\n
25
\n\n
42
\n\n
44
\n\n
65
\n\n
20
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jun 21, 2025
\n
\n
\n\n
\n
\n
\n\n
3
\n\n
16
\n\n
32
\n\n
52
\n\n
62
\n\n
24
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jun 18, 2025
\n
\n
\n\n
\n
\n
\n\n
23
\n\n
29
\n\n
50
\n\n
64
\n\n
67
\n\n
11
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jun 16, 2025
\n
\n
\n\n
\n
\n
\n\n
17
\n\n
21
\n\n
23
\n\n
27
\n\n
52
\n\n
19
\n
\n
\n\n \n Power Play\n 5x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jun 14, 2025
\n
\n
\n\n
\n
\n
\n\n
4
\n\n
6
\n\n
9
\n\n
23
\n\n
59
\n\n
25
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jun 11, 2025
\n
\n
\n\n
\n
\n
\n\n
13
\n\n
25
\n\n
29
\n\n
37
\n\n
53
\n\n
3
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jun 9, 2025
\n
\n
\n\n
\n
\n
\n\n
30
\n\n
33
\n\n
40
\n\n
43
\n\n
52
\n\n
25
\n
\n
\n\n \n Power Play\n 4x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, Jun 7, 2025
\n
\n
\n\n
\n
\n
\n\n
31
\n\n
36
\n\n
43
\n\n
48
\n\n
62
\n\n
25
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, Jun 4, 2025
\n
\n
\n\n
\n
\n
\n\n
5
\n\n
17
\n\n
23
\n\n
35
\n\n
45
\n\n
24
\n
\n
\n\n \n Power Play\n 10x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, Jun 2, 2025
\n
\n
\n\n
\n
\n
\n\n
1
\n\n
7
\n\n
44
\n\n
57
\n\n
61
\n\n
21
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, May 31, 2025
\n
\n
\n\n
\n
\n
\n\n
1
\n\n
29
\n\n
37
\n\n
56
\n\n
68
\n\n
13
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, May 28, 2025
\n
\n
\n\n
\n
\n
\n\n
23
\n\n
27
\n\n
32
\n\n
35
\n\n
59
\n\n
11
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, May 26, 2025
\n
\n
\n\n
\n
\n
\n\n
13
\n\n
47
\n\n
52
\n\n
64
\n\n
67
\n\n
25
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, May 24, 2025
\n
\n
\n\n
\n
\n
\n\n
12
\n\n
18
\n\n
28
\n\n
48
\n\n
52
\n\n
5
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, May 21, 2025
\n
\n
\n\n
\n
\n
\n\n
9
\n\n
29
\n\n
31
\n\n
34
\n\n
43
\n\n
2
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, May 19, 2025
\n
\n
\n\n
\n
\n
\n\n
13
\n\n
14
\n\n
37
\n\n
50
\n\n
60
\n\n
11
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Sat, May 17, 2025
\n
\n
\n\n
\n
\n
\n\n
7
\n\n
34
\n\n
40
\n\n
42
\n\n
52
\n\n
15
\n
\n
\n\n \n Power Play\n 2x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Wed, May 14, 2025
\n
\n
\n\n
\n
\n
\n\n
4
\n\n
10
\n\n
24
\n\n
29
\n\n
53
\n\n
4
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
Mon, May 12, 2025
\n
\n
\n\n
\n
\n
\n\n
15
\n\n
16
\n\n
41
\n\n
48
\n\n
60
\n\n
21
\n
\n
\n\n \n Power Play\n 3x\n \n
\n
\n
\n
\n\n
\n\n
\n \n \n \n
\n
\n
\n
\n \n
\n\n
\n
\n
\n\n\n\n\n\n
\n\n\n\n\n \n\n\n" - } -] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca4ed34 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "lottery-tracker" +version = "2.0.0" +description = "Lottery Investment Calculator β€” jackpots, tax analysis, and investment projections" +requires-python = ">=3.11" + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "SIM", # simplify + "RUF", # ruff-specific +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.isort] +known-first-party = ["config", "scrapers", "lottery_calculator"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-q --tb=short" diff --git a/read_excel.py b/read_excel.py deleted file mode 100644 index 620f4d1..0000000 --- a/read_excel.py +++ /dev/null @@ -1,31 +0,0 @@ -import openpyxl -import pandas as pd - -# Load the workbook -wb = openpyxl.load_workbook('Max.xlsx') -print(f"Sheets: {wb.sheetnames}\n") - -# Read each sheet -for sheet_name in wb.sheetnames: - print(f"\n{'='*60}") - print(f"SHEET: {sheet_name}") - print('='*60) - ws = wb[sheet_name] - - # Print first 30 rows - for i, row in enumerate(ws.iter_rows(values_only=True), 1): - if any(cell is not None for cell in row): # Skip completely empty rows - print(f"Row {i}: {row}") - if i >= 30: - break - -print("\n\nNow using pandas for better formatting:") -print("="*60) - -# Try reading with pandas -for sheet_name in wb.sheetnames: - print(f"\n\nSheet: {sheet_name}") - print("-"*60) - df = pd.read_excel('Max.xlsx', sheet_name=sheet_name) - print(df.head(20)) - print(f"\nColumns: {df.columns.tolist()}") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..920f268 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest==8.3.4 +pytest-mock==3.14.0 +pytest-cov==6.0.0 +httpx==0.28.1 +ruff==0.9.4 diff --git a/requirements.txt b/requirements.txt index a588b53..07e9c6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -flask -flask-cors -requests -beautifulsoup4 -playwright -urllib3 -openpyxl -pandas -schedule +flask==3.1.0 +flask-cors==5.0.1 +requests==2.32.3 +beautifulsoup4==4.12.3 +playwright==1.49.1 +gunicorn==23.0.0 +cachetools==5.5.1 +python-dotenv==1.0.1 +certifi==2024.12.14 diff --git a/scrapers.py b/scrapers.py new file mode 100644 index 0000000..5f9170b --- /dev/null +++ b/scrapers.py @@ -0,0 +1,191 @@ +""" +Unified lottery jackpot scrapers with TTL caching. + +Consolidates all scraping logic that was previously duplicated across +app.py, email_sender.py, and ``import requests.py``. +""" + +from __future__ import annotations + +import logging +import re +import time + +import requests +from bs4 import BeautifulSoup +from playwright.sync_api import sync_playwright + +from config import load_config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Browser-like request headers +# --------------------------------------------------------------------------- +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": ( + "text/html,application/xhtml+xml,application/xml;" + "q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + ), + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Cache-Control": "max-age=0", +} + +# --------------------------------------------------------------------------- +# Simple TTL cache +# --------------------------------------------------------------------------- +_cache: dict[str, tuple[float, object]] = {} + + +def _get_cached(key: str, ttl: int) -> object | None: + """Return cached value if it exists and hasn't expired.""" + entry = _cache.get(key) + if entry is None: + return None + ts, value = entry + if time.time() - ts > ttl: + return None + return value + + +def _set_cached(key: str, value: object) -> None: + _cache[key] = (time.time(), value) + + +def clear_cache() -> None: + """Clear the scraper cache (useful for testing or forcing refresh).""" + _cache.clear() + + +# --------------------------------------------------------------------------- +# Individual scrapers +# --------------------------------------------------------------------------- + +def _parse_jackpot_from_lotto_net(html: str) -> float | None: + """Extract the *Next Jackpot* dollar amount from a lotto.net page.""" + soup = BeautifulSoup(html, "html.parser") + text = soup.get_text() + lines = text.split("\n") + for i, line in enumerate(lines): + if "Next Jackpot" in line and i + 1 < len(lines): + next_line = lines[i + 1].strip() + if "$" in next_line: + match = re.search(r"\$(\d+(?:,\d+)?(?:\.\d+)?)", next_line) + if match: + value = float(match.group(1).replace(",", "")) + if "Billion" in next_line: + return value * 1_000_000_000 + if "Million" in next_line: + return value * 1_000_000 + return value + return None + + +def scrape_powerball(url: str | None = None) -> float | None: + """Scrape the current Powerball jackpot from lotto.net.""" + cfg = load_config() + target = url or cfg.urls.powerball + try: + resp = requests.get(target, timeout=15, headers=HEADERS) + resp.raise_for_status() + return _parse_jackpot_from_lotto_net(resp.text) + except Exception: + logger.exception("Failed to scrape Powerball from %s", target) + return None + + +def scrape_mega_millions(url: str | None = None) -> float | None: + """Scrape the current Mega Millions jackpot from lotto.net.""" + cfg = load_config() + target = url or cfg.urls.mega_millions + try: + resp = requests.get(target, timeout=15, headers=HEADERS) + resp.raise_for_status() + return _parse_jackpot_from_lotto_net(resp.text) + except Exception: + logger.exception("Failed to scrape Mega Millions from %s", target) + return None + + +def scrape_canadian_lotteries(url: str | None = None) -> dict[str, float | None]: + """Scrape Lotto Max and Lotto 6/49 from OLG using Playwright.""" + cfg = load_config() + target = url or cfg.urls.olg + results: dict[str, float | None] = {"lottoMax": None, "lotto649": None} + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + page.goto(target, wait_until="networkidle", timeout=30000) + page.wait_for_timeout(3000) + content = page.content() + browser.close() + + # Lotto Max + max_match = re.search( + r"LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million", + content, + re.IGNORECASE | re.DOTALL, + ) + if max_match: + results["lottoMax"] = float(max_match.group(1).replace(",", "")) * 1_000_000 + + # Lotto 6/49 + match_649 = re.search( + r"LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million", + content, + re.IGNORECASE | re.DOTALL, + ) + if match_649: + results["lotto649"] = float(match_649.group(1).replace(",", "")) * 1_000_000 + + except Exception: + logger.exception("Failed to scrape Canadian lotteries from %s", target) + + return results + + +# --------------------------------------------------------------------------- +# Aggregated fetchers (with cache) +# --------------------------------------------------------------------------- + +def get_all_jackpots(*, force_refresh: bool = False) -> dict: + """Return all four lottery jackpots, using cache when available. + + Returns:: + + { + "us": {"powerball": float|None, "megaMillions": float|None}, + "canadian": {"lottoMax": float|None, "lotto649": float|None}, + } + """ + cfg = load_config() + cache_key = "all_jackpots" + + if not force_refresh: + cached = _get_cached(cache_key, cfg.cache_ttl) + if cached is not None: + return cached # type: ignore[return-value] + + pb = scrape_powerball() + mm = scrape_mega_millions() + canadian = scrape_canadian_lotteries() + + result = { + "us": {"powerball": pb, "megaMillions": mm}, + "canadian": canadian, + } + _set_cached(cache_key, result) + return result diff --git a/send_email_now.py b/send_email_now.py deleted file mode 100644 index 510fddb..0000000 --- a/send_email_now.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Secure email sender that prompts for password instead of storing it. -This version is safer and works without App Passwords. -""" -import asyncio -from email_sender import ( - get_powerball, - get_mega_millions, - get_canadian_lotteries, - create_email_html, - format_currency -) -from datetime import datetime -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -import getpass - -def send_email_secure(sender_email, sender_password, recipient_email, subject, html_content): - """Send email with provided credentials""" - try: - # Create message - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = sender_email - msg['To'] = recipient_email - - # Attach HTML content - html_part = MIMEText(html_content, 'html') - msg.attach(html_part) - - # Try Gmail first - try: - print(" Trying Gmail SMTP...") - with smtplib.SMTP('smtp.gmail.com', 587) as server: - server.starttls() - server.login(sender_email, sender_password) - server.send_message(msg) - print(f"βœ… Email sent successfully via Gmail!") - return True - except Exception as gmail_error: - print(f" Gmail failed: {gmail_error}") - - # Try alternative method - Gmail SSL port - try: - print(" Trying Gmail SSL (port 465)...") - with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server: - server.login(sender_email, sender_password) - server.send_message(msg) - print(f"βœ… Email sent successfully via Gmail SSL!") - return True - except Exception as ssl_error: - print(f" Gmail SSL also failed: {ssl_error}") - raise - except Exception as e: - print(f"❌ Error sending email: {e}") - print("\n⚠️ Common issues:") - print(" 1. Gmail requires 2-Step Verification + App Password") - print(" 2. Check if 'Less secure app access' is enabled (not recommended)") - print(" 3. Verify your email and password are correct") - return False - -def send_lottery_email(): - """Fetch jackpots and send email with secure password prompt""" - print("\n" + "="*60) - print("🎰 LOTTERY JACKPOT EMAIL SENDER") - print("="*60) - - # Email configuration - sender_email = "mblanke@gmail.com" - recipient_email = "mblanke@gmail.com" - - print(f"\nπŸ“§ Email will be sent from/to: {sender_email}") - print("\nπŸ” Please enter your Gmail password:") - print(" (Note: Gmail may require an App Password if you have 2FA enabled)") - - # Securely prompt for password (won't show on screen) - sender_password = getpass.getpass(" Password: ") - - if not sender_password: - print("❌ No password provided. Exiting.") - return - - print("\n" + "-"*60) - print("πŸ“Š Fetching lottery jackpots...") - print("-"*60) - - # Get US lotteries - print("\nπŸ‡ΊπŸ‡Έ US Lotteries:") - print(" Fetching Powerball...") - powerball = get_powerball() - print(f" βœ“ Powerball: {format_currency(powerball)}") - - print(" Fetching Mega Millions...") - mega_millions = get_mega_millions() - print(f" βœ“ Mega Millions: {format_currency(mega_millions)}") - - # Get Canadian lotteries - print("\nπŸ‡¨πŸ‡¦ Canadian Lotteries:") - print(" Fetching Lotto Max and Lotto 6/49...") - lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries()) - print(f" βœ“ Lotto Max: {format_currency(lotto_max)}") - print(f" βœ“ Lotto 6/49: {format_currency(lotto_649)}") - - # Create email - print("\n" + "-"*60) - print("πŸ“§ Creating email...") - print("-"*60) - subject = f"🎰 Lottery Report - {datetime.now().strftime('%B %d, %Y')}" - html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649) - print(" βœ“ Email content created") - - # Send email - print("\nπŸ“€ Sending email...") - success = send_email_secure(sender_email, sender_password, recipient_email, subject, html_content) - - if success: - print("\n" + "="*60) - print("βœ… SUCCESS!") - print("="*60) - print(f"πŸ“§ Check your inbox at: {recipient_email}") - print("πŸ’‘ The email includes all current jackpot amounts") - print(" with beautiful HTML formatting!") - else: - print("\n" + "="*60) - print("❌ FAILED!") - print("="*60) - print("\nπŸ”§ Options to fix:") - print(" 1. Enable 2-Step Verification in Gmail") - print(" 2. Generate App Password: https://myaccount.google.com/apppasswords") - print(" 3. Use the App Password instead of regular password") - print("\n Alternative: Use a different email service (Outlook, Yahoo, etc.)") - - print("\n") - -if __name__ == "__main__": - send_lottery_email() diff --git a/ssl/README.md b/ssl/README.md new file mode 100644 index 0000000..e71e743 --- /dev/null +++ b/ssl/README.md @@ -0,0 +1,17 @@ +# SSL Certificate Setup +# +# Place your SSL certificates in this directory: +# ssl/cert.pem β€” your certificate (or fullchain) +# ssl/key.pem β€” your private key +# +# To generate self-signed certs for local testing: +# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ +# -keyout ssl/key.pem -out ssl/cert.pem \ +# -subj "/CN=localhost" +# +# For production, use Let's Encrypt: +# certbot certonly --standalone -d your-domain.com +# cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem +# cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem +# +# Then uncomment the HTTPS server block in nginx.conf. diff --git a/test_email.py b/test_email.py deleted file mode 100644 index 5728d89..0000000 --- a/test_email.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Quick test script to send a lottery jackpot email immediately. -Use this to verify your email configuration before scheduling. -""" -import asyncio -from email_sender import ( - get_powerball, - get_mega_millions, - get_canadian_lotteries, - create_email_html, - send_email, - format_currency, - EMAIL_CONFIG -) -from datetime import datetime - -def test_email(): - """Test the email sender by sending immediately""" - print("\n" + "="*60) - print("πŸ§ͺ TESTING LOTTERY EMAIL SENDER") - print("="*60) - - # Display current configuration - print(f"\nπŸ“§ Email Configuration:") - print(f" From: {EMAIL_CONFIG['sender_email']}") - print(f" To: {EMAIL_CONFIG['recipient_email']}") - print(f" SMTP Server: {EMAIL_CONFIG['smtp_server']}:{EMAIL_CONFIG['smtp_port']}") - - if EMAIL_CONFIG['sender_email'] == 'your-email@gmail.com': - print("\n⚠️ WARNING: You need to update EMAIL_CONFIG in email_sender.py!") - print(" Please edit the file and add your email credentials.") - print(" See EMAIL_SETUP.md for instructions.") - return - - print("\n" + "-"*60) - print("πŸ“Š Fetching lottery jackpots...") - print("-"*60) - - # Get US lotteries - print("\nπŸ‡ΊπŸ‡Έ US Lotteries:") - print(" Fetching Powerball...") - powerball = get_powerball() - print(f" βœ“ Powerball: {format_currency(powerball)}") - - print(" Fetching Mega Millions...") - mega_millions = get_mega_millions() - print(f" βœ“ Mega Millions: {format_currency(mega_millions)}") - - # Get Canadian lotteries - print("\nπŸ‡¨πŸ‡¦ Canadian Lotteries:") - print(" Fetching Lotto Max and Lotto 6/49...") - lotto_max, lotto_649 = asyncio.run(get_canadian_lotteries()) - print(f" βœ“ Lotto Max: {format_currency(lotto_max)}") - print(f" βœ“ Lotto 6/49: {format_currency(lotto_649)}") - - # Create email - print("\n" + "-"*60) - print("πŸ“§ Creating email...") - print("-"*60) - subject = f"🎰 TEST - Lottery Report - {datetime.now().strftime('%B %d, %Y')}" - html_content = create_email_html(powerball, mega_millions, lotto_max, lotto_649) - print(" βœ“ Email content created") - - # Send email - print("\nπŸ“€ Sending email...") - success = send_email(subject, html_content) - - if success: - print("\n" + "="*60) - print("βœ… TEST SUCCESSFUL!") - print("="*60) - print(f"πŸ“§ Check your inbox at: {EMAIL_CONFIG['recipient_email']}") - print("πŸ’‘ If everything looks good, you can run email_sender.py") - print(" to schedule daily emails at 7:00 AM") - else: - print("\n" + "="*60) - print("❌ TEST FAILED!") - print("="*60) - print("πŸ” Troubleshooting tips:") - print(" 1. Check your email and password in EMAIL_CONFIG") - print(" 2. For Gmail, use an App Password (not your regular password)") - print(" 3. Verify SMTP server and port are correct") - print(" 4. Check your internet connection") - print(" 5. See EMAIL_SETUP.md for detailed instructions") - - print("\n") - -if __name__ == "__main__": - test_email() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..884a3e4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Shared pytest fixtures for the Lottery Tracker test suite.""" + +from __future__ import annotations + +import pytest + +from app import create_app + + +@pytest.fixture() +def app(): + """Create a test Flask app.""" + application = create_app() + application.config["TESTING"] = True + return application + + +@pytest.fixture() +def client(app): + """Flask test client.""" + return app.test_client() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c7bdf6c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,215 @@ +"""Integration tests for Flask API endpoints.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# /api/health +# --------------------------------------------------------------------------- + +def test_health(client): + resp = client.get("/api/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "ok" + + +# --------------------------------------------------------------------------- +# /api/jackpots +# --------------------------------------------------------------------------- + +@patch("app.get_all_jackpots") +def test_jackpots(mock_get, client): + mock_get.return_value = { + "us": {"powerball": 500_000_000, "megaMillions": 300_000_000}, + "canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000}, + } + resp = client.get("/api/jackpots") + assert resp.status_code == 200 + data = resp.get_json() + assert data["us"]["powerball"] == 500_000_000 + + +# --------------------------------------------------------------------------- +# /api/calculate +# --------------------------------------------------------------------------- + +def test_calculate_us(client): + resp = client.post( + "/api/calculate", + json={"jackpot": 100_000_000, "type": "us"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["country"] == "US" + assert data["originalJackpot"] == 100_000_000 + + +def test_calculate_canadian(client): + resp = client.post( + "/api/calculate", + json={"jackpot": 50_000_000, "type": "canadian"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["country"] == "Canada" + + +def test_calculate_with_state(client): + resp = client.post( + "/api/calculate", + json={"jackpot": 100_000_000, "type": "us", "state": "CA"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["stateTaxRate"] == 0.133 # California + + +def test_calculate_missing_jackpot(client): + resp = client.post("/api/calculate", json={"type": "us"}) + assert resp.status_code == 400 + + +def test_calculate_bad_type(client): + resp = client.post("/api/calculate", json={"jackpot": 100, "type": "mars"}) + assert resp.status_code == 400 + + +def test_calculate_no_body(client): + resp = client.post("/api/calculate") + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# /api/states +# --------------------------------------------------------------------------- + +def test_states_list(client): + resp = client.get("/api/states") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 51 # 50 states + DC + codes = [s["code"] for s in data] + assert "CA" in codes + assert "TX" in codes + + +def test_state_by_code(client): + resp = client.get("/api/states/NY") + assert resp.status_code == 200 + data = resp.get_json() + assert data["name"] == "New York" + assert data["rate"] == 0.109 + + +def test_state_not_found(client): + resp = client.get("/api/states/ZZ") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# /api/compare +# --------------------------------------------------------------------------- + +@patch("app.get_all_jackpots") +def test_compare(mock_get, client): + mock_get.return_value = { + "us": {"powerball": 500_000_000, "megaMillions": 300_000_000}, + "canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000}, + } + resp = client.get("/api/compare") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 4 + names = [d["name"] for d in data] + assert "Powerball" in names + + +# --------------------------------------------------------------------------- +# /api/calculate/breakeven +# --------------------------------------------------------------------------- + +def test_breakeven(client): + resp = client.post( + "/api/calculate/breakeven", + json={"lottery": "powerball"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["breakEvenJackpot"] > 0 + assert data["lottery"] == "Powerball" + + +def test_breakeven_unknown_lottery(client): + resp = client.post( + "/api/calculate/breakeven", + json={"lottery": "nosuchlottery"}, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# /api/calculate/annuity +# --------------------------------------------------------------------------- + +def test_annuity(client): + resp = client.post( + "/api/calculate/annuity", + json={"jackpot": 500_000_000, "type": "us", "years": 30}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert len(data["schedule"]) == 30 + + +def test_annuity_canadian(client): + resp = client.post( + "/api/calculate/annuity", + json={"jackpot": 100_000_000, "type": "canadian"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["country"] == "canadian" + assert data["schedule"][0]["tax"] == 0.0 + + +# --------------------------------------------------------------------------- +# /api/calculate/group +# --------------------------------------------------------------------------- + +def test_group_play(client): + resp = client.post( + "/api/calculate/group", + json={"jackpot": 100_000_000, "members": 4, "type": "us"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["members"] == 4 + assert len(data["memberResults"]) == 4 + + +def test_group_custom_shares(client): + resp = client.post( + "/api/calculate/group", + json={"jackpot": 100_000_000, "members": 2, "shares": [0.7, 0.3], "type": "canadian"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["memberResults"][0]["share"] == pytest.approx(0.7) + + +# --------------------------------------------------------------------------- +# /api/odds +# --------------------------------------------------------------------------- + +def test_odds(client): + resp = client.get("/api/odds") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data) == 4 + names = [d["name"] for d in data] + assert "Powerball" in names + assert "Mega Millions" in names diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..99864de --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,47 @@ +"""Tests for config.py.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +from config import LOTTERY_ODDS, STATE_TAX_RATES, load_config + + +def test_all_states_present(): + assert len(STATE_TAX_RATES) == 51 # 50 states + DC + + +def test_all_lottery_odds_present(): + assert "powerball" in LOTTERY_ODDS + assert "megaMillions" in LOTTERY_ODDS + assert "lottoMax" in LOTTERY_ODDS + assert "lotto649" in LOTTERY_ODDS + + +def test_load_config_defaults(): + cfg = load_config() + assert cfg.debug is False + assert cfg.port == 5000 + assert cfg.tax.lump_sum_rate == 0.52 + assert cfg.investment.cycles == 8 + + +@patch.dict(os.environ, {"FLASK_DEBUG": "true", "FLASK_PORT": "8080"}) +def test_load_config_from_env(): + cfg = load_config() + assert cfg.debug is True + assert cfg.port == 8080 + + +@patch.dict(os.environ, {"LUMP_SUM_RATE": "0.60"}) +def test_tax_config_from_env(): + cfg = load_config() + assert cfg.tax.lump_sum_rate == 0.60 + + +def test_no_tax_states(): + """Florida, Texas, Nevada, etc. should have 0% tax.""" + no_tax = ["AK", "FL", "NV", "NH", "SD", "TN", "TX", "WA", "WY"] + for code in no_tax: + assert STATE_TAX_RATES[code]["rate"] == 0.0, f"{code} should have 0% tax" diff --git a/tests/test_lottery_calculator.py b/tests/test_lottery_calculator.py new file mode 100644 index 0000000..7745748 --- /dev/null +++ b/tests/test_lottery_calculator.py @@ -0,0 +1,207 @@ +"""Unit tests for lottery_calculator.py β€” pure calculation logic.""" + +from __future__ import annotations + +import pytest + +from lottery_calculator import ( + calculate_annuity, + calculate_break_even, + calculate_canadian_lottery, + calculate_group_split, + calculate_us_lottery, +) + +# --------------------------------------------------------------------------- +# calculate_us_lottery +# --------------------------------------------------------------------------- + +class TestCalculateUSLottery: + """Tests for the US lottery calculation.""" + + def test_basic_calculation(self): + result = calculate_us_lottery(100_000_000) + assert result["country"] == "US" + assert result["originalJackpot"] == 100_000_000 + + def test_cash_sum_is_lump_sum_rate(self): + result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52) + assert result["cashSum"] == pytest.approx(52_000_000) + + def test_federal_tax_applied(self): + result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52, federal_tax_rate=0.37) + assert result["federalTax"] == pytest.approx(52_000_000 * 0.37) + + def test_state_tax_applied(self): + result = calculate_us_lottery(100_000_000, state_tax_rate=0.10) + assert result["stateTaxRate"] == 0.10 + + def test_net_amount_usd(self): + result = calculate_us_lottery( + 100_000_000, lump_sum_rate=0.50, federal_tax_rate=0.30, state_tax_rate=0.05 + ) + expected = 50_000_000 * (1 - 0.30 - 0.05) + assert result["netAmountUsd"] == pytest.approx(expected) + + def test_cad_conversion(self): + result = calculate_us_lottery(100_000_000, usd_cad_rate=1.40) + assert result["netAmountCad"] == pytest.approx(result["netAmountUsd"] * 1.40) + + def test_investment_split(self): + result = calculate_us_lottery(100_000_000, invest_percentage=0.80) + total = result["investmentPrincipal"] + result["funMoney"] + assert total == pytest.approx(result["netAmountCad"]) + assert result["funMoney"] == pytest.approx(result["netAmountCad"] * 0.20) + + def test_cycles_count(self): + result = calculate_us_lottery(100_000_000, cycles=5) + assert len(result["cycles"]) == 5 + + def test_principal_grows(self): + result = calculate_us_lottery(100_000_000, cycles=4) + for i in range(1, len(result["cycles"])): + assert result["cycles"][i]["principalStart"] >= result["cycles"][i - 1]["principalStart"] + + def test_final_principal_positive(self): + result = calculate_us_lottery(500_000_000) + assert result["finalPrincipal"] > 0 + + def test_daily_income_positive(self): + result = calculate_us_lottery(500_000_000) + assert result["netDailyIncome"] > 0 + + def test_zero_state_tax(self): + """Florida / Texas have 0% state tax.""" + result = calculate_us_lottery(100_000_000, state_tax_rate=0.0) + assert result["stateTax"] == 0.0 + assert result["netAmountUsd"] > calculate_us_lottery(100_000_000, state_tax_rate=0.10)["netAmountUsd"] + + +# --------------------------------------------------------------------------- +# calculate_canadian_lottery +# --------------------------------------------------------------------------- + +class TestCalculateCanadianLottery: + """Tests for Canadian lottery calculation (tax-free winnings).""" + + def test_basic_calculation(self): + result = calculate_canadian_lottery(50_000_000) + assert result["country"] == "Canada" + assert result["originalJackpot"] == 50_000_000 + + def test_tax_free(self): + result = calculate_canadian_lottery(50_000_000) + assert result["netAmountCad"] == 50_000_000 + + def test_investment_split(self): + result = calculate_canadian_lottery(50_000_000, invest_percentage=0.90) + assert result["investmentPrincipal"] == pytest.approx(45_000_000) + assert result["funMoney"] == pytest.approx(5_000_000) + + def test_cycles_count(self): + result = calculate_canadian_lottery(50_000_000, cycles=3) + assert len(result["cycles"]) == 3 + + def test_daily_income_positive(self): + result = calculate_canadian_lottery(50_000_000) + assert result["netDailyIncome"] > 0 + + +# --------------------------------------------------------------------------- +# calculate_break_even +# --------------------------------------------------------------------------- + +class TestCalculateBreakEven: + """Tests for the break-even expected-value calculator.""" + + def test_us_break_even(self): + result = calculate_break_even(odds=292_201_338, ticket_cost=2.0, country="us") + assert result["breakEvenJackpot"] > 0 + assert result["breakEvenJackpot"] > 292_201_338 # must be >> odds because of tax + + def test_canadian_break_even(self): + result = calculate_break_even(odds=13_983_816, ticket_cost=3.0, country="canadian") + # Canadian take-home fraction = 1.0, so break-even = 3 * 13_983_816 + expected = 3.0 * 13_983_816 + assert result["breakEvenJackpot"] == pytest.approx(expected) + + def test_ev_equals_ticket_cost(self): + result = calculate_break_even(odds=100, ticket_cost=5.0, country="canadian") + # EV at break even = probability * jackpot * 1.0 = 5.0 + assert result["expectedValueAtBreakEven"] == pytest.approx(5.0, rel=1e-6) + + def test_higher_tax_needs_bigger_jackpot(self): + r1 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.0) + r2 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.10) + assert r2["breakEvenJackpot"] > r1["breakEvenJackpot"] + + +# --------------------------------------------------------------------------- +# calculate_annuity +# --------------------------------------------------------------------------- + +class TestCalculateAnnuity: + """Tests for the annuity payout schedule.""" + + def test_us_annuity_schedule_length(self): + result = calculate_annuity(500_000_000, country="us", years=30) + assert len(result["schedule"]) == 30 + + def test_canadian_no_tax(self): + result = calculate_annuity(100_000_000, country="canadian", years=10, annual_increase=0.0) + for entry in result["schedule"]: + assert entry["tax"] == 0.0 + assert entry["afterTax"] == entry["preTax"] + + def test_total_pretax_approximates_jackpot(self): + result = calculate_annuity(100_000_000, country="canadian", years=30, annual_increase=0.05) + assert result["totalPreTax"] == pytest.approx(100_000_000, rel=1e-6) + + def test_payments_increase_annually(self): + result = calculate_annuity(100_000_000, years=5, annual_increase=0.05) + for i in range(1, len(result["schedule"])): + assert result["schedule"][i]["preTax"] > result["schedule"][i - 1]["preTax"] + + def test_zero_increase(self): + result = calculate_annuity(100_000_000, years=5, annual_increase=0.0) + payments = [e["preTax"] for e in result["schedule"]] + assert all(p == pytest.approx(payments[0]) for p in payments) + + +# --------------------------------------------------------------------------- +# calculate_group_split +# --------------------------------------------------------------------------- + +class TestCalculateGroupSplit: + """Tests for the group play calculator.""" + + def test_equal_split(self): + result = calculate_group_split(100_000_000, members=4, country="canadian") + assert len(result["memberResults"]) == 4 + for m in result["memberResults"]: + assert m["jackpotShare"] == pytest.approx(25_000_000) + + def test_custom_shares(self): + shares = [0.5, 0.3, 0.2] + result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian") + assert result["memberResults"][0]["jackpotShare"] == pytest.approx(50_000_000) + assert result["memberResults"][1]["jackpotShare"] == pytest.approx(30_000_000) + assert result["memberResults"][2]["jackpotShare"] == pytest.approx(20_000_000) + + def test_shares_normalize(self): + """Shares that don't sum to 1.0 should be normalised.""" + shares = [2, 2, 1] # sums to 5 + result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian") + assert result["memberResults"][0]["share"] == pytest.approx(0.4) + assert result["memberResults"][2]["share"] == pytest.approx(0.2) + + def test_mismatched_shares_falls_back_to_equal(self): + result = calculate_group_split(100_000_000, members=3, shares=[0.5, 0.5], country="canadian") + for m in result["memberResults"]: + assert m["share"] == pytest.approx(1 / 3) + + def test_each_member_gets_calculation(self): + result = calculate_group_split(100_000_000, members=2, country="us") + for m in result["memberResults"]: + assert "calculation" in m + assert m["calculation"]["country"] == "US" diff --git a/tests/test_scrapers.py b/tests/test_scrapers.py new file mode 100644 index 0000000..cb79cf1 --- /dev/null +++ b/tests/test_scrapers.py @@ -0,0 +1,111 @@ +"""Tests for scrapers.py β€” uses mocks to avoid real HTTP calls.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from scrapers import ( + _parse_jackpot_from_lotto_net, + clear_cache, + get_all_jackpots, + scrape_mega_millions, + scrape_powerball, +) + +# --------------------------------------------------------------------------- +# HTML parser unit tests +# --------------------------------------------------------------------------- + +POWERBALL_HTML = """ + +

Powerball

+
+Next Jackpot +$350 Million +
+ +""" + +BILLION_HTML = """ + +
+Next Jackpot +$1.5 Billion +
+ +""" + + +def test_parse_jackpot_millions(): + result = _parse_jackpot_from_lotto_net(POWERBALL_HTML) + assert result == 350_000_000 + + +def test_parse_jackpot_billions(): + result = _parse_jackpot_from_lotto_net(BILLION_HTML) + assert result == 1_500_000_000 + + +def test_parse_jackpot_no_match(): + result = _parse_jackpot_from_lotto_net("No jackpot here") + assert result is None + + +# --------------------------------------------------------------------------- +# Scraper integration tests (mocked HTTP) +# --------------------------------------------------------------------------- + +@patch("scrapers.requests.get") +def test_scrape_powerball_success(mock_get): + mock_resp = MagicMock() + mock_resp.text = POWERBALL_HTML + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + result = scrape_powerball() + assert result == 350_000_000 + + +@patch("scrapers.requests.get") +def test_scrape_powerball_failure(mock_get): + mock_get.side_effect = Exception("Network error") + result = scrape_powerball() + assert result is None + + +@patch("scrapers.requests.get") +def test_scrape_mega_millions_success(mock_get): + html = POWERBALL_HTML.replace("Powerball", "Mega Millions") + mock_resp = MagicMock() + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + mock_get.return_value = mock_resp + + result = scrape_mega_millions() + assert result == 350_000_000 + + +# --------------------------------------------------------------------------- +# Cache tests +# --------------------------------------------------------------------------- + +@patch("scrapers.scrape_powerball", return_value=100_000_000) +@patch("scrapers.scrape_mega_millions", return_value=200_000_000) +@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000}) +def test_get_all_jackpots_caches(mock_ca, mock_mm, mock_pb): + clear_cache() + r1 = get_all_jackpots() + r2 = get_all_jackpots() + # Second call should use cache β€” scrapers called only once + assert mock_pb.call_count == 1 + assert r1 == r2 + + +@patch("scrapers.scrape_powerball", return_value=100_000_000) +@patch("scrapers.scrape_mega_millions", return_value=200_000_000) +@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000}) +def test_get_all_jackpots_force_refresh(mock_ca, mock_mm, mock_pb): + clear_cache() + get_all_jackpots() + get_all_jackpots(force_refresh=True) + assert mock_pb.call_count == 2