mirror of
https://github.com/mblanke/Lottery-Tracker.git
synced 2026-03-01 14:10:22 -05:00
Compare commits
2 Commits
main
...
6d3475efe9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3475efe9 | |||
| fdba869a8d |
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
ENV/
|
ENV/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@@ -13,6 +14,7 @@ ENV/
|
|||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|||||||
35
.env.example
Normal file
35
.env.example
Normal file
@@ -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
|
||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -23,3 +23,23 @@ Thumbs.db
|
|||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
*.csv
|
||||||
|
|
||||||
|
# Debug HTML
|
||||||
|
*.html
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
ssl/*.pem
|
||||||
|
ssl/*.key
|
||||||
|
ssl/*.crt
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
|||||||
14
AGENTS.md
14
AGENTS.md
@@ -64,13 +64,13 @@ Model: Auto (override to the strongest available for high-stakes diffs).
|
|||||||
- Prefer deterministic, reproducible steps.
|
- Prefer deterministic, reproducible steps.
|
||||||
- Cite sources when generating documents from a knowledge base.
|
- Cite sources when generating documents from a knowledge base.
|
||||||
|
|
||||||
## Repo facts (fill these in)
|
## Repo facts
|
||||||
- Primary stack:
|
- Primary stack: Python 3.13 (Flask 3.1) + TypeScript (Next.js 15, MUI 6)
|
||||||
- Package manager:
|
- Package manager: pip (backend) + npm (frontend)
|
||||||
- Test command:
|
- Test command: `pytest -q`
|
||||||
- Lint/format command:
|
- Lint/format command: `ruff check . --fix`
|
||||||
- Build command (if any):
|
- Build command (if any): `docker compose build`
|
||||||
- Deployment (if any):
|
- Deployment (if any): `docker compose -f docker-compose.prod.yml up -d`
|
||||||
|
|
||||||
|
|
||||||
## Claude Code Agents (optional)
|
## Claude Code Agents (optional)
|
||||||
|
|||||||
@@ -1,255 +1,92 @@
|
|||||||
# 🐋 Docker Setup Complete!
|
# Docker Quick Start
|
||||||
|
|
||||||
## What's Been Created
|
## What's Included
|
||||||
|
|
||||||
### Docker Files
|
### Docker Files
|
||||||
- ✅ `Dockerfile.backend` - Flask API with Playwright
|
- `Dockerfile.backend` — Flask API with Playwright & Chromium
|
||||||
- ✅ `Dockerfile.frontend` - Next.js app optimized for production
|
- `Dockerfile.frontend` — Next.js standalone production build
|
||||||
- ✅ `Dockerfile.email` - Email scheduler service
|
- `docker-compose.yml` — Development setup (backend + frontend)
|
||||||
- ✅ `docker-compose.yml` - Development setup
|
- `docker-compose.prod.yml` — Production setup (+ nginx reverse proxy)
|
||||||
- ✅ `docker-compose.prod.yml` - Production setup with nginx
|
- `nginx.conf` — Reverse proxy with rate limiting & caching
|
||||||
- ✅ `.dockerignore` - Optimized build context
|
- `.env.example` — All available environment variables
|
||||||
- ✅ `requirements.txt` - Python dependencies
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
- ✅ Updated `next.config.ts` for standalone output
|
|
||||||
- ✅ Created startup scripts (Windows & Linux)
|
|
||||||
- ✅ Complete documentation in `DOCKER_README.md`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Windows Script (Easiest)
|
### 1. Configure
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
docker-start.bat
|
docker-start.bat
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
./docker-start.sh
|
||||||
|
|
||||||
|
# Or directly
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Docker Compose
|
### 3. Open
|
||||||
```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`:
|
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000
|
- **Frontend**: http://localhost:3000
|
||||||
- **Backend API**: http://localhost:5000
|
- **Backend API**: http://localhost:5000
|
||||||
- **Health Check**: http://localhost:5000/api/health
|
- **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
|
```bash
|
||||||
# All services
|
# View logs
|
||||||
docker-compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
# Specific service
|
# Restart
|
||||||
docker-compose logs -f backend
|
docker compose restart
|
||||||
docker-compose logs -f frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restart Services
|
# Rebuild after code changes
|
||||||
```bash
|
docker compose up -d --build
|
||||||
docker-compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop Everything
|
# Stop
|
||||||
```bash
|
docker compose down
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rebuild After Changes
|
# Production
|
||||||
```bash
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Port Already in Use
|
1. **Check Docker Desktop is running**
|
||||||
If ports 3000 or 5000 are busy:
|
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
|
See `DOCKER_README.md` for detailed troubleshooting.
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
netstat -ano | findstr :3000
|
|
||||||
taskkill /PID <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 <your-repo>
|
|
||||||
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! 🎰🐋
|
|
||||||
|
|||||||
348
DOCKER_README.md
348
DOCKER_README.md
@@ -1,54 +1,56 @@
|
|||||||
# Lottery Investment Calculator - Docker Setup
|
# Lottery Investment Calculator - Docker Setup
|
||||||
|
|
||||||
## 🐋 Docker Deployment Guide
|
## Prerequisites
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Docker Desktop installed (https://www.docker.com/products/docker-desktop)
|
- Docker Desktop installed (https://www.docker.com/products/docker-desktop)
|
||||||
- Docker Compose (included with Docker Desktop)
|
- Docker Compose (included with Docker Desktop)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Build and Run Everything
|
### 1. Configure Environment
|
||||||
```bash
|
```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
|
- **Backend API** on http://localhost:5000
|
||||||
- **Frontend Web App** on http://localhost:3000
|
- **Frontend Web App** on http://localhost:3000
|
||||||
|
|
||||||
### 2. Check Status
|
### 3. Check Status
|
||||||
```bash
|
```bash
|
||||||
docker-compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. View Logs
|
### 4. View Logs
|
||||||
```bash
|
```bash
|
||||||
# All services
|
# All services
|
||||||
docker-compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
# Just backend
|
# Specific service
|
||||||
docker-compose logs -f backend
|
docker compose logs -f backend
|
||||||
|
docker compose logs -f frontend
|
||||||
# Just frontend
|
|
||||||
docker-compose logs -f frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Stop Everything
|
### 5. Stop
|
||||||
```bash
|
```bash
|
||||||
docker-compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Individual Services
|
## Individual Services
|
||||||
|
|
||||||
### Backend Only
|
### Backend Only
|
||||||
```bash
|
```bash
|
||||||
docker build -f Dockerfile.backend -t lottery-backend .
|
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
|
### Frontend Only
|
||||||
@@ -57,291 +59,139 @@ docker build -f Dockerfile.frontend -t lottery-frontend .
|
|||||||
docker run -p 3000:3000 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
|
### Internal URLs (container to container)
|
||||||
|
|
||||||
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):
|
|
||||||
- Backend: `http://backend:5000`
|
- Backend: `http://backend:5000`
|
||||||
- Frontend: `http://frontend:3000`
|
- Frontend: `http://frontend:3000`
|
||||||
|
|
||||||
### External URLs (host to container):
|
### External URLs (host machine)
|
||||||
- Backend: `http://localhost:5000`
|
- Backend: `http://localhost:5000`
|
||||||
- Frontend: `http://localhost:3000`
|
- Frontend: `http://localhost:3000`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Health Checks
|
## Health Checks
|
||||||
|
|
||||||
The backend includes a health check endpoint:
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5000/api/health
|
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
|
```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 <your-repo>
|
||||||
|
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-backend yourusername/lottery-backend:latest
|
||||||
docker tag lottery-frontend yourusername/lottery-frontend:latest
|
docker tag lottery-frontend yourusername/lottery-frontend:latest
|
||||||
|
|
||||||
# Push to Docker Hub
|
|
||||||
docker push yourusername/lottery-backend:latest
|
docker push yourusername/lottery-backend:latest
|
||||||
docker push yourusername/lottery-frontend: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
|
```bash
|
||||||
# Check logs
|
# Windows
|
||||||
docker logs lottery-backend
|
netstat -ano | findstr :5000
|
||||||
|
taskkill /PID <PID> /F
|
||||||
|
|
||||||
# Common issues:
|
# Or change ports in docker-compose.yml
|
||||||
# - Port 5000 already in use
|
|
||||||
# - Playwright installation failed
|
|
||||||
# - Missing dependencies
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend can't connect to backend
|
### Backend Won't Start
|
||||||
```bash
|
```bash
|
||||||
# Check if backend is running
|
docker logs lottery-backend
|
||||||
docker-compose ps
|
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
|
curl http://localhost:5000/api/health
|
||||||
|
|
||||||
# Check frontend environment
|
|
||||||
docker exec lottery-frontend env | grep API_URL
|
docker exec lottery-frontend env | grep API_URL
|
||||||
```
|
```
|
||||||
|
|
||||||
### Playwright browser issues
|
### Playwright Browser Issues
|
||||||
```bash
|
```bash
|
||||||
# Rebuild with no cache
|
docker compose build --no-cache backend
|
||||||
docker-compose build --no-cache backend
|
|
||||||
|
|
||||||
# Check Playwright installation
|
|
||||||
docker exec lottery-backend playwright --version
|
docker exec lottery-backend playwright --version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Container keeps restarting
|
### Access Container Shell
|
||||||
```bash
|
```bash
|
||||||
# View logs
|
docker exec -it lottery-backend /bin/bash
|
||||||
docker logs lottery-backend --tail 100
|
docker exec -it lottery-frontend /bin/sh
|
||||||
|
```
|
||||||
|
|
||||||
# Check health status
|
### Clean Everything
|
||||||
docker inspect lottery-backend | grep -A 5 Health
|
```bash
|
||||||
|
docker compose down -v --rmi all
|
||||||
|
docker system prune -a
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Useful Commands
|
## Resource Usage
|
||||||
|
|
||||||
### Access Container Shell
|
Production limits (set in `docker-compose.prod.yml`):
|
||||||
```bash
|
- Backend: 2 GB RAM, 1 CPU
|
||||||
# Backend
|
- Frontend: 512 MB RAM, 0.5 CPU
|
||||||
docker exec -it lottery-backend /bin/bash
|
- Nginx: 256 MB RAM, 0.25 CPU
|
||||||
|
|
||||||
# Frontend
|
### Monitor
|
||||||
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
|
|
||||||
```bash
|
```bash
|
||||||
docker stats
|
docker stats
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚢 Alternative: Docker without Compose
|
## Image Sizes (Approximate)
|
||||||
|
|
||||||
### Create Network
|
- Backend: ~1.5 GB (includes Chromium for Playwright)
|
||||||
```bash
|
- Frontend: ~200 MB (Next.js standalone)
|
||||||
docker network create lottery-network
|
- Nginx: ~30 MB
|
||||||
```
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ COPY requirements.txt .
|
|||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
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 chromium
|
||||||
RUN playwright install-deps chromium
|
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY lottery_calculator.py .
|
COPY lottery_calculator.py .
|
||||||
COPY ["import requests.py", "."]
|
COPY config.py .
|
||||||
|
COPY scrapers.py .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
@@ -51,7 +51,8 @@ EXPOSE 5000
|
|||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV FLASK_APP=app.py
|
ENV FLASK_APP=app.py
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
|
ENV FLASK_DEBUG=false
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Run the application
|
# Run with gunicorn for production
|
||||||
CMD ["python", "app.py"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
208
EMAIL_SETUP.md
208
EMAIL_SETUP.md
@@ -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
|
|
||||||
@@ -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}")
|
|
||||||
573
app.py
573
app.py
@@ -1,172 +1,443 @@
|
|||||||
"""
|
"""
|
||||||
Flask Backend for Lottery Investment Calculator
|
Flask Backend for Lottery Investment Calculator.
|
||||||
Provides API endpoints for jackpots and investment calculations
|
|
||||||
|
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 import Flask, jsonify, request
|
||||||
from flask_cors import CORS
|
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
|
from config import (
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
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 = {
|
# App factory
|
||||||
"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_us_lotteries():
|
def create_app() -> Flask:
|
||||||
"""Fetch Powerball and Mega Millions jackpots"""
|
"""Application factory — creates and configures the Flask app."""
|
||||||
results = {"Powerball": None, "Mega Millions": None}
|
cfg = load_config()
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
def get_canadian_lotteries():
|
# CORS — restrict origins via env or allow all in dev
|
||||||
"""Fetch Lotto Max and Lotto 6/49 jackpots using Playwright"""
|
if cfg.allowed_origins == "*":
|
||||||
results = {"Lotto Max": None, "Lotto 6/49": None}
|
CORS(app)
|
||||||
|
else:
|
||||||
try:
|
CORS(app, origins=[o.strip() for o in cfg.allowed_origins.split(",")])
|
||||||
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
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Validation helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route('/api/jackpots', methods=['GET'])
|
def _require_json() -> dict | None:
|
||||||
def get_jackpots():
|
"""Parse JSON body or return None."""
|
||||||
"""API endpoint to get all lottery jackpots"""
|
return request.get_json(silent=True)
|
||||||
us_lotteries = get_us_lotteries()
|
|
||||||
canadian_lotteries = get_canadian_lotteries()
|
def _validate_number(
|
||||||
|
value, name: str, *, minimum: float = 0, maximum: float | None = None
|
||||||
return jsonify({
|
) -> float | None:
|
||||||
"us": {
|
"""Coerce *value* to float and validate range. Returns None on bad input."""
|
||||||
"powerball": us_lotteries["Powerball"],
|
try:
|
||||||
"megaMillions": us_lotteries["Mega Millions"]
|
v = float(value)
|
||||||
},
|
except (TypeError, ValueError):
|
||||||
"canadian": {
|
return None
|
||||||
"lottoMax": canadian_lotteries["Lotto Max"],
|
if v < minimum:
|
||||||
"lotto649": canadian_lotteries["Lotto 6/49"]
|
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/<code>", 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'])
|
if amount and amount > 0:
|
||||||
def calculate():
|
try:
|
||||||
"""API endpoint to calculate investment returns"""
|
if country_type == "us":
|
||||||
data = request.json
|
calc = calculate_us_lottery(
|
||||||
|
amount,
|
||||||
jackpot = data.get('jackpot')
|
state_tax_rate=state_tax,
|
||||||
lottery_type = data.get('type', 'us') # 'us' or 'canadian'
|
lump_sum_rate=cfg.tax.lump_sum_rate,
|
||||||
invest_percentage = data.get('investPercentage', 0.90)
|
federal_tax_rate=cfg.tax.federal_tax_rate,
|
||||||
annual_return = data.get('annualReturn', 0.045)
|
usd_cad_rate=cfg.tax.usd_cad_rate,
|
||||||
cycles = data.get('cycles', 8)
|
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||||
|
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||||
if not jackpot:
|
)
|
||||||
return jsonify({"error": "Jackpot amount is required"}), 400
|
else:
|
||||||
|
calc = calculate_canadian_lottery(
|
||||||
try:
|
amount,
|
||||||
if lottery_type == 'us':
|
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
|
||||||
result = calculate_us_lottery(jackpot, invest_percentage, annual_return, cycles)
|
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
|
||||||
else:
|
)
|
||||||
result = calculate_canadian_lottery(jackpot, invest_percentage, annual_return, cycles)
|
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)
|
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():
|
# Entry point
|
||||||
"""Health check endpoint"""
|
# ---------------------------------------------------------------------------
|
||||||
return jsonify({"status": "ok"})
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
if __name__ == '__main__':
|
cfg = load_config()
|
||||||
print("🎰 Lottery Investment Calculator API")
|
logger.info("Lottery Investment Calculator API v2.0")
|
||||||
print("=" * 50)
|
logger.info("Endpoints:")
|
||||||
print("Starting Flask server on http://localhost:5000")
|
logger.info(" GET /api/jackpots - Current jackpots (cached)")
|
||||||
print("API Endpoints:")
|
logger.info(" POST /api/jackpots/refresh - Force refresh")
|
||||||
print(" - GET /api/jackpots - Get current jackpots")
|
logger.info(" POST /api/calculate - Investment calculator")
|
||||||
print(" - POST /api/calculate - Calculate investments")
|
logger.info(" POST /api/calculate/breakeven - Break-even calculator")
|
||||||
print(" - GET /api/health - Health check")
|
logger.info(" POST /api/calculate/annuity - Annuity calculator")
|
||||||
print("=" * 50)
|
logger.info(" POST /api/calculate/group - Group play calculator")
|
||||||
# Bind to 0.0.0.0 so the Flask app is reachable from outside the container
|
logger.info(" GET /api/compare - Side-by-side comparison")
|
||||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
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)
|
||||||
|
|||||||
212
config.py
Normal file
212
config.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Nginx Reverse Proxy
|
# Nginx Reverse Proxy
|
||||||
nginx:
|
nginx:
|
||||||
@@ -26,14 +24,17 @@ services:
|
|||||||
container_name: lottery-backend
|
container_name: lottery-backend
|
||||||
expose:
|
expose:
|
||||||
- "5000"
|
- "5000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
|
- FLASK_DEBUG=false
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- lottery-network
|
- lottery-network
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -41,10 +42,10 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '1'
|
cpus: "1"
|
||||||
memory: 2G
|
memory: 2G
|
||||||
reservations:
|
reservations:
|
||||||
cpus: '0.5'
|
cpus: "0.5"
|
||||||
memory: 1G
|
memory: 1G
|
||||||
|
|
||||||
# Next.js Frontend
|
# Next.js Frontend
|
||||||
@@ -52,11 +53,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: /api
|
||||||
container_name: lottery-frontend
|
container_name: lottery-frontend
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=http://backend:5000
|
- NEXT_PUBLIC_API_URL=/api
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
@@ -66,29 +69,12 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: '0.5'
|
cpus: "0.5"
|
||||||
memory: 512M
|
memory: 512M
|
||||||
reservations:
|
reservations:
|
||||||
cpus: '0.25'
|
cpus: "0.25"
|
||||||
memory: 256M
|
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:
|
networks:
|
||||||
lottery-network:
|
lottery-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ services:
|
|||||||
container_name: lottery-backend
|
container_name: lottery-backend
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
|
- FLASK_DEBUG=false
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- lottery-network
|
- lottery-network
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -40,20 +43,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- lottery-network
|
- 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:
|
networks:
|
||||||
lottery-network:
|
lottery-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
329
email_sender.py
329
email_sender.py
@@ -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"""
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
body {{
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
padding: 20px;
|
|
||||||
}}
|
|
||||||
.container {{
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 30px;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}}
|
|
||||||
h1 {{
|
|
||||||
color: #2c3e50;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}}
|
|
||||||
.lottery-section {{
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}}
|
|
||||||
.lottery-section h2 {{
|
|
||||||
color: #34495e;
|
|
||||||
border-bottom: 2px solid #3498db;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}}
|
|
||||||
.lottery-item {{
|
|
||||||
background-color: #ecf0f1;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}}
|
|
||||||
.lottery-name {{
|
|
||||||
font-weight: bold;
|
|
||||||
color: #2c3e50;
|
|
||||||
font-size: 16px;
|
|
||||||
}}
|
|
||||||
.lottery-amount {{
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #27ae60;
|
|
||||||
}}
|
|
||||||
.tax-free {{
|
|
||||||
background-color: #2ecc71;
|
|
||||||
color: white;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}}
|
|
||||||
.footer {{
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #ecf0f1;
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 12px;
|
|
||||||
}}
|
|
||||||
.timestamp {{
|
|
||||||
color: #95a5a6;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🎰 Daily Lottery Jackpots</h1>
|
|
||||||
|
|
||||||
<div class="lottery-section">
|
|
||||||
<h2>🇺🇸 US Lotteries</h2>
|
|
||||||
<div class="lottery-item">
|
|
||||||
<div>
|
|
||||||
<span class="lottery-name">Powerball</span>
|
|
||||||
</div>
|
|
||||||
<span class="lottery-amount">{format_currency(powerball)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lottery-item">
|
|
||||||
<div>
|
|
||||||
<span class="lottery-name">Mega Millions</span>
|
|
||||||
</div>
|
|
||||||
<span class="lottery-amount">{format_currency(mega_millions)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lottery-section">
|
|
||||||
<h2>🇨🇦 Canadian Lotteries</h2>
|
|
||||||
<div class="lottery-item">
|
|
||||||
<div>
|
|
||||||
<span class="lottery-name">Lotto Max</span>
|
|
||||||
<span class="tax-free">TAX FREE</span>
|
|
||||||
</div>
|
|
||||||
<span class="lottery-amount">{format_currency(lotto_max)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lottery-item">
|
|
||||||
<div>
|
|
||||||
<span class="lottery-name">Lotto 6/49</span>
|
|
||||||
<span class="tax-free">TAX FREE</span>
|
|
||||||
</div>
|
|
||||||
<span class="lottery-amount">{format_currency(lotto_649)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p>💡 Remember: Canadian lottery winnings are tax-free!</p>
|
|
||||||
<p>📊 Visit your Lottery Investment Calculator for detailed analysis</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timestamp">
|
|
||||||
Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -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']}")
|
|
||||||
@@ -1,195 +1,344 @@
|
|||||||
"""
|
"""
|
||||||
Lottery Investment Calculator
|
Lottery Investment Calculator — pure calculation logic.
|
||||||
Handles both US and Canadian lottery calculations
|
|
||||||
|
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:
|
Args:
|
||||||
jackpot: Original jackpot amount (USD)
|
jackpot: Advertised jackpot amount (USD).
|
||||||
invest_percentage: Percentage to invest (default 90%)
|
invest_percentage: Fraction to invest (0-1).
|
||||||
annual_return: Annual return rate (default 4.5%)
|
annual_return: Expected annual return rate.
|
||||||
cycles: Number of 90-day cycles to calculate (default 8)
|
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 * lump_sum_rate
|
||||||
cash_sum = jackpot * 0.52 # Lump sum is 52%
|
federal_tax = cash_sum * federal_tax_rate
|
||||||
federal_tax = cash_sum * 0.37
|
state_tax = cash_sum * state_tax_rate
|
||||||
state_tax = cash_sum * 0.055
|
|
||||||
net_amount = cash_sum - federal_tax - state_tax
|
net_amount = cash_sum - federal_tax - state_tax
|
||||||
|
|
||||||
# Convert to Canadian dollars
|
canadian_amount = net_amount * usd_cad_rate
|
||||||
canadian_amount = net_amount * 1.35
|
|
||||||
|
|
||||||
# Split into investment and fun money
|
|
||||||
investment_principal = canadian_amount * invest_percentage
|
investment_principal = canadian_amount * invest_percentage
|
||||||
fun_money = canadian_amount * (1 - invest_percentage)
|
fun_money = canadian_amount * (1 - invest_percentage)
|
||||||
|
|
||||||
# Calculate cycles
|
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
|
||||||
cycle_results = []
|
investment_principal,
|
||||||
principal = investment_principal
|
annual_return,
|
||||||
total_personal_withdrawals = 0
|
cycles,
|
||||||
|
investment_income_tax_rate,
|
||||||
for cycle in range(1, cycles + 1):
|
personal_withdrawal_pct,
|
||||||
# Interest for 90 days
|
)
|
||||||
interest_earned = principal * annual_return * (90/365)
|
|
||||||
|
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'country': 'US',
|
"country": "US",
|
||||||
'original_jackpot': jackpot,
|
"originalJackpot": jackpot,
|
||||||
'cash_sum': cash_sum,
|
"cashSum": cash_sum,
|
||||||
'federal_tax': federal_tax,
|
"federalTax": federal_tax,
|
||||||
'state_tax': state_tax,
|
"stateTax": state_tax,
|
||||||
'net_amount_usd': net_amount,
|
"stateTaxRate": state_tax_rate,
|
||||||
'net_amount_cad': canadian_amount,
|
"netAmountUsd": net_amount,
|
||||||
'investment_principal': investment_principal,
|
"netAmountCad": canadian_amount,
|
||||||
'fun_money': fun_money,
|
"investmentPrincipal": investment_principal,
|
||||||
'net_daily_income': net_daily_income,
|
"funMoney": fun_money,
|
||||||
'annual_income': net_daily_income * 365,
|
"netDailyIncome": net_daily_income,
|
||||||
'total_personal_withdrawals': total_personal_withdrawals,
|
"annualIncome": net_daily_income * 365,
|
||||||
'final_principal': principal,
|
"totalPersonalWithdrawals": total_withdrawals,
|
||||||
'cycles': cycle_results
|
"finalPrincipal": final_principal,
|
||||||
|
"cycles": cycle_results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def calculate_canadian_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8):
|
def calculate_canadian_lottery(
|
||||||
"""
|
jackpot: float,
|
||||||
Calculate investment returns for Canadian lottery winnings
|
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:
|
Args:
|
||||||
jackpot: Original jackpot amount (CAD) - TAX FREE!
|
jackpot: Jackpot amount (CAD) - no tax deducted on winnings.
|
||||||
invest_percentage: Percentage to invest (default 90%)
|
invest_percentage: Fraction to invest (0-1).
|
||||||
annual_return: Annual return rate (default 4.5%)
|
annual_return: Expected annual return rate.
|
||||||
cycles: Number of 90-day cycles to calculate (default 8)
|
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 # Tax-free!
|
||||||
net_amount = jackpot
|
|
||||||
|
|
||||||
# Split into investment and fun money
|
|
||||||
investment_principal = net_amount * invest_percentage
|
investment_principal = net_amount * invest_percentage
|
||||||
fun_money = net_amount * (1 - invest_percentage)
|
fun_money = net_amount * (1 - invest_percentage)
|
||||||
|
|
||||||
# Calculate cycles
|
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
|
||||||
cycle_results = []
|
investment_principal,
|
||||||
principal = investment_principal
|
annual_return,
|
||||||
total_personal_withdrawals = 0
|
cycles,
|
||||||
|
investment_income_tax_rate,
|
||||||
for cycle in range(1, cycles + 1):
|
personal_withdrawal_pct,
|
||||||
# Interest for 90 days
|
)
|
||||||
interest_earned = principal * annual_return * (90/365)
|
|
||||||
|
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'country': 'Canada',
|
"country": "Canada",
|
||||||
'original_jackpot': jackpot,
|
"originalJackpot": jackpot,
|
||||||
'net_amount_cad': net_amount,
|
"netAmountCad": net_amount,
|
||||||
'investment_principal': investment_principal,
|
"investmentPrincipal": investment_principal,
|
||||||
'fun_money': fun_money,
|
"funMoney": fun_money,
|
||||||
'net_daily_income': net_daily_income,
|
"netDailyIncome": net_daily_income,
|
||||||
'annual_income': net_daily_income * 365,
|
"annualIncome": net_daily_income * 365,
|
||||||
'total_personal_withdrawals': total_personal_withdrawals,
|
"totalPersonalWithdrawals": total_withdrawals,
|
||||||
'final_principal': principal,
|
"finalPrincipal": final_principal,
|
||||||
'cycles': cycle_results
|
"cycles": cycle_results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
# ---------------------------------------------------------------------------
|
||||||
# Test with current jackpots
|
# Break-even calculator
|
||||||
print("=" * 80)
|
# ---------------------------------------------------------------------------
|
||||||
print("US LOTTERY - MEGA MILLIONS ($547M)")
|
|
||||||
print("=" * 80)
|
def calculate_break_even(
|
||||||
us_result = calculate_us_lottery(547_000_000)
|
odds: int,
|
||||||
print(f"Original Jackpot: ${us_result['original_jackpot']:,.0f}")
|
ticket_cost: float,
|
||||||
print(f"Cash Sum (52%): ${us_result['cash_sum']:,.0f}")
|
country: str = "us",
|
||||||
print(f"After Taxes (USD): ${us_result['net_amount_usd']:,.0f}")
|
*,
|
||||||
print(f"After Taxes (CAD): ${us_result['net_amount_cad']:,.0f}")
|
lump_sum_rate: float = 0.52,
|
||||||
print(f"Investment (90%): ${us_result['investment_principal']:,.0f}")
|
federal_tax_rate: float = 0.37,
|
||||||
print(f"Fun Money (10%): ${us_result['fun_money']:,.0f}")
|
state_tax_rate: float = 0.055,
|
||||||
print(f"Daily Income: ${us_result['net_daily_income']:,.2f}")
|
) -> dict:
|
||||||
print(f"Annual Income: ${us_result['annual_income']:,.2f}")
|
"""Calculate the jackpot where expected value >= ticket cost.
|
||||||
print(f"Final Principal (after 8 cycles): ${us_result['final_principal']:,.0f}")
|
|
||||||
|
For US lotteries the take-home fraction is::
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("CANADIAN LOTTERY - LOTTO 6/49 ($32M CAD)")
|
lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
|
||||||
print("=" * 80)
|
|
||||||
can_result = calculate_canadian_lottery(32_000_000)
|
For Canadian lotteries the full jackpot is kept (tax-free).
|
||||||
print(f"Original Jackpot (TAX FREE!): ${can_result['original_jackpot']:,.0f}")
|
|
||||||
print(f"Investment (90%): ${can_result['investment_principal']:,.0f}")
|
Returns a dict with the break-even jackpot and supporting details.
|
||||||
print(f"Fun Money (10%): ${can_result['fun_money']:,.0f}")
|
"""
|
||||||
print(f"Daily Income: ${can_result['net_daily_income']:,.2f}")
|
if country == "us":
|
||||||
print(f"Annual Income: ${can_result['annual_income']:,.2f}")
|
take_home_fraction = lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
|
||||||
print(f"Final Principal (after 8 cycles): ${can_result['final_principal']:,.0f}")
|
else:
|
||||||
|
take_home_fraction = 1.0
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("COMPARISON")
|
# EV = (jackpot * take_home_fraction) / odds >= ticket_cost
|
||||||
print("=" * 80)
|
# => jackpot >= ticket_cost * odds / take_home_fraction
|
||||||
print(f"US ($547M) - You keep: ${us_result['net_amount_cad']:,.0f} CAD after taxes")
|
break_even_jackpot = (ticket_cost * odds) / take_home_fraction
|
||||||
print(f"Canadian ($32M) - You keep: ${can_result['net_amount_cad']:,.0f} CAD (NO TAXES!)")
|
probability = 1 / odds
|
||||||
print(f"\nUS Daily Income: ${us_result['net_daily_income']:,.2f}")
|
|
||||||
print(f"Canadian Daily Income: ${can_result['net_daily_income']:,.2f}")
|
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,
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
77
nginx.conf
Normal file
77
nginx.conf
Normal file
@@ -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;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
@@ -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"
|
||||||
@@ -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()}")
|
|
||||||
6
requirements-dev.txt
Normal file
6
requirements-dev.txt
Normal file
@@ -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
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
flask
|
flask==3.1.0
|
||||||
flask-cors
|
flask-cors==5.0.1
|
||||||
requests
|
requests==2.32.3
|
||||||
beautifulsoup4
|
beautifulsoup4==4.12.3
|
||||||
playwright
|
playwright==1.49.1
|
||||||
urllib3
|
gunicorn==23.0.0
|
||||||
openpyxl
|
cachetools==5.5.1
|
||||||
pandas
|
python-dotenv==1.0.1
|
||||||
schedule
|
certifi==2024.12.14
|
||||||
|
|||||||
191
scrapers.py
Normal file
191
scrapers.py
Normal file
@@ -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
|
||||||
@@ -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()
|
|
||||||
17
ssl/README.md
Normal file
17
ssl/README.md
Normal file
@@ -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.
|
||||||
@@ -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()
|
|
||||||
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
@@ -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()
|
||||||
215
tests/test_api.py
Normal file
215
tests/test_api.py
Normal file
@@ -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
|
||||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -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"
|
||||||
207
tests/test_lottery_calculator.py
Normal file
207
tests/test_lottery_calculator.py
Normal file
@@ -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"
|
||||||
111
tests/test_scrapers.py
Normal file
111
tests/test_scrapers.py
Normal file
@@ -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 = """
|
||||||
|
<html><body>
|
||||||
|
<h2>Powerball</h2>
|
||||||
|
<div>
|
||||||
|
Next Jackpot
|
||||||
|
$350 Million
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
BILLION_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<div>
|
||||||
|
Next Jackpot
|
||||||
|
$1.5 Billion
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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("<html><body>No jackpot here</body></html>")
|
||||||
|
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
|
||||||
203
updates.md
Normal file
203
updates.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Lottery Tracker — Update Log
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Complete codebase overhaul: cleanup, modernization, new features, and full Next.js frontend build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Code Review & Cleanup
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
- `email_sender.py` — Email sending logic (removed entire email system)
|
||||||
|
- `send_email_now.py` — Manual email trigger
|
||||||
|
- `test_email.py` — Email tests
|
||||||
|
- `analyze_excel.py` — Excel analysis utility
|
||||||
|
- `read_excel.py` — Excel reader utility
|
||||||
|
- `import requests.py` — Misnamed utility script
|
||||||
|
- `powerball_numbers.html` — Debug HTML file
|
||||||
|
- `megamillions_debug.html` — Debug HTML file
|
||||||
|
- `Dockerfile.email` — Email service Docker image
|
||||||
|
- `EMAIL_SETUP.md` — Email configuration docs
|
||||||
|
|
||||||
|
### Issues Fixed
|
||||||
|
- **Hardcoded credentials** removed (Gmail password in docker docs)
|
||||||
|
- **`verify=False`** on all HTTP requests replaced with default SSL verification
|
||||||
|
- **Code duplication** — scraping logic was copy-pasted across 3 files, consolidated into one
|
||||||
|
- **No tests** — full test suite added
|
||||||
|
- **No linting** — ruff configured
|
||||||
|
- **Flask `debug=True`** hardcoded — replaced with env var
|
||||||
|
- **Docker healthcheck** used `curl` (not installed in slim image) — switched to Python urllib
|
||||||
|
- **`.dockerignore`** missing `.venv/` — build context was 456 MB, now much smaller
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Backend Rewrite
|
||||||
|
|
||||||
|
### New Modules
|
||||||
|
|
||||||
|
#### `config.py`
|
||||||
|
- `AppConfig` dataclass with nested `ScraperURLs`, `TaxConfig`, `InvestmentDefaults`
|
||||||
|
- `STATE_TAX_RATES` — all 51 US states + DC with 2024-2025 tax rates
|
||||||
|
- `LOTTERY_ODDS` — odds and ticket costs for all 4 lotteries
|
||||||
|
- `ANNUITY_YEARS`, `ANNUITY_ANNUAL_INCREASE` constants
|
||||||
|
- `load_config()` reads from environment variables with sensible defaults
|
||||||
|
|
||||||
|
#### `scrapers.py`
|
||||||
|
- Unified scraping with TTL cache (6-hour default via `cachetools`)
|
||||||
|
- `scrape_powerball()` / `scrape_mega_millions()` — requests + BeautifulSoup from lotto.net
|
||||||
|
- `scrape_canadian_lotteries()` — Playwright sync API from olg.ca
|
||||||
|
- `get_all_jackpots()` — returns all 4 jackpots with cache
|
||||||
|
- `clear_cache()` — force refresh
|
||||||
|
|
||||||
|
#### `app.py` (Rewritten)
|
||||||
|
Flask app factory pattern (`create_app()`) with endpoints:
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/jackpots` | GET | Current jackpots (cached) |
|
||||||
|
| `/api/jackpots/refresh` | POST | Force-refresh jackpots |
|
||||||
|
| `/api/calculate` | POST | Full investment calculation with state tax |
|
||||||
|
| `/api/states` | GET | All US states with tax rates |
|
||||||
|
| `/api/states/<code>` | GET | Single state info |
|
||||||
|
| `/api/compare` | GET | Side-by-side all 4 lotteries |
|
||||||
|
| `/api/calculate/breakeven` | POST | Break-even jackpot analysis |
|
||||||
|
| `/api/calculate/annuity` | POST | Annuity payment schedule |
|
||||||
|
| `/api/calculate/group` | POST | Group play split |
|
||||||
|
| `/api/odds` | GET | Probability data for all lotteries |
|
||||||
|
| `/api/health` | GET | Health check |
|
||||||
|
|
||||||
|
#### `lottery_calculator.py` (Rewritten)
|
||||||
|
Pure calculation functions with explicit parameters:
|
||||||
|
- `_run_investment_cycles()` — shared 90-day reinvestment cycle logic
|
||||||
|
- `calculate_us_lottery()` — lump sum + federal/state tax + CAD conversion + cycles
|
||||||
|
- `calculate_canadian_lottery()` — tax-free + investment cycles
|
||||||
|
- `calculate_break_even()` — EV formula: required jackpot for positive expected value
|
||||||
|
- `calculate_annuity()` — 30-year schedule with configurable annual increase
|
||||||
|
- `calculate_group_split()` — N-way split with custom share ratios
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
- `requirements.txt` — pinned versions, removed openpyxl/pandas/schedule
|
||||||
|
- `requirements-dev.txt` — pytest, pytest-mock, pytest-cov, httpx, ruff
|
||||||
|
- `pyproject.toml` — ruff config (line-length=100, py311 target), pytest config
|
||||||
|
- `.env.example` — all environment variables documented
|
||||||
|
|
||||||
|
### Test Suite (64 tests, all passing)
|
||||||
|
- `tests/conftest.py` — Flask test fixtures
|
||||||
|
- `tests/test_lottery_calculator.py` — ~25 tests (US calc, Canadian calc, break-even, annuity, group split)
|
||||||
|
- `tests/test_api.py` — ~15 endpoint integration tests with mocked scrapers
|
||||||
|
- `tests/test_config.py` — config loading, env override, state validation
|
||||||
|
- `tests/test_scrapers.py` — parser tests, mocked HTTP, cache behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Frontend (Next.js 15 + MUI 6)
|
||||||
|
|
||||||
|
### Scaffold
|
||||||
|
- `frontend/package.json` — Next.js 15.1.4, MUI 6.3.1, Recharts 2.15, Axios
|
||||||
|
- `frontend/tsconfig.json` — standard Next.js TypeScript config with `@/*` paths
|
||||||
|
- `frontend/next.config.js` — standalone output, `/api/*` rewrite to backend
|
||||||
|
- `frontend/.env.local` — `NEXT_PUBLIC_API_URL=http://localhost:5000`
|
||||||
|
|
||||||
|
### Shared Libraries
|
||||||
|
- `frontend/lib/types.ts` — Full TypeScript interfaces (JackpotData, Calculation, StateInfo, BreakEvenResult, AnnuityResult, GroupResult, OddsInfo, etc.)
|
||||||
|
- `frontend/lib/api.ts` — Typed Axios client with all API endpoint functions
|
||||||
|
- `frontend/lib/format.ts` — `formatCurrency`, `formatCurrencyFull`, `formatPercent`, `formatNumber`
|
||||||
|
- `frontend/lib/theme.ts` — Dark MUI theme (primary: `#4fc3f7`, background: `#0a1929`)
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `frontend/components/ThemeRegistry.tsx` — MUI ThemeProvider wrapper with CssBaseline
|
||||||
|
- `frontend/components/NavBar.tsx` — Sticky app bar with navigation links
|
||||||
|
|
||||||
|
### Pages (7 routes)
|
||||||
|
|
||||||
|
| Route | File | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/` | `app/page.tsx` | Home — jackpot cards grid (4 lotteries) + tool cards grid (6 tools) |
|
||||||
|
| `/calculator` | `app/calculator/page.tsx` | Investment calculator — jackpot input, lottery type, state selector with tax rates, invest % slider, annual return slider, cycles, results summary, 90-day cycle table |
|
||||||
|
| `/compare` | `app/compare/page.tsx` | Side-by-side comparison — state selector, 4 lottery cards with jackpot, odds, after-tax take-home, daily/annual income |
|
||||||
|
| `/breakeven` | `app/breakeven/page.tsx` | Break-even analysis — lottery/state selectors, required jackpot, odds, probability, take-home fraction, expected value |
|
||||||
|
| `/annuity` | `app/annuity/page.tsx` | Annuity calculator — jackpot, type, years, annual increase slider, summary cards, year-by-year payment schedule table |
|
||||||
|
| `/group` | `app/group/page.tsx` | Group play — member count, custom share weights, per-member breakdown table (share %, jackpot share, after-tax, daily/annual income) |
|
||||||
|
| `/odds` | `app/odds/page.tsx` | Probability display — odds, percentage, ticket cost, log-scale relative bar, "50% chance" ticket count |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Docker & Infrastructure
|
||||||
|
|
||||||
|
### Docker Images Built
|
||||||
|
| Image | Size | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `lottery-tracker-backend` | 2.28 GB | Python 3.13-slim + Playwright Chromium + Gunicorn |
|
||||||
|
| `lottery-tracker-frontend` | 285 MB | Node.js 20-alpine + Next.js standalone |
|
||||||
|
|
||||||
|
### Docker Fixes Applied
|
||||||
|
1. **Removed `playwright install-deps chromium`** from Dockerfile.backend — system deps already installed manually; the auto-installer fails on newer Debian due to renamed font packages (`ttf-unifont` → `fonts-unifont`)
|
||||||
|
2. **Generated `package-lock.json`** — required by `npm ci` in frontend Dockerfile
|
||||||
|
3. **Created `frontend/public/`** directory with `manifest.json` — Dockerfile COPY step requires it
|
||||||
|
4. **Added `.venv/` to `.dockerignore`** — was inflating build context
|
||||||
|
5. **Removed `version: '3.8'`** warning — obsolete in modern Docker Compose
|
||||||
|
6. **Removed email-scheduler service** from `docker-compose.yml`
|
||||||
|
7. **Switched healthcheck** from `curl` to `python -c "import urllib.request; ..."`
|
||||||
|
8. **Backend CMD** changed to gunicorn (2 workers, 120s timeout)
|
||||||
|
|
||||||
|
### MUI Grid Fix
|
||||||
|
All 7 frontend pages used `<Grid size={{ xs: 12, sm: 6 }}>` syntax (Grid2 API), but imported `Grid` from `@mui/material` (legacy Grid v1). Fixed by changing imports to `Grid2 as Grid` in all pages.
|
||||||
|
|
||||||
|
### Running Containers
|
||||||
|
| Container | Status | Port Mapping |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `lottery-backend` | Up (healthy) | `0.0.0.0:5000 → 5000` |
|
||||||
|
| `lottery-frontend` | Up | `0.0.0.0:3003 → 3000` |
|
||||||
|
|
||||||
|
### Access URLs
|
||||||
|
- **Frontend**: http://localhost:3003
|
||||||
|
- **Backend API**: http://localhost:5000/api/health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Documentation & Config
|
||||||
|
|
||||||
|
### Updated Files
|
||||||
|
- **`.gitignore`** — added `.next/`, `*.xlsx`, `*.html`, `ssl/` certs, coverage outputs
|
||||||
|
- **`AGENTS.md`** — filled in repo facts (Python 3.13/Flask + Next.js 15/MUI 6, pytest, ruff, docker compose)
|
||||||
|
- **`DOCKER_README.md`** — rewritten: removed email references, removed hardcoded password, added `.env.example` workflow, config table, production deployment guide
|
||||||
|
- **`DOCKER_QUICKSTART.md`** — rewritten: removed email service, updated container descriptions, simplified quick start
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **64/64 tests passing** (`pytest -q`)
|
||||||
|
- **ruff lint clean** (`ruff check .` — all checks passed)
|
||||||
|
- **Docker build successful** — both images built
|
||||||
|
- **Containers running** — backend healthy, frontend serving
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ nginx (prod) │
|
||||||
|
│ Rate limiting + SSL │
|
||||||
|
├─────────────────┬───────────────────────────────┤
|
||||||
|
│ Frontend │ Backend │
|
||||||
|
│ Next.js 15 │ Flask 3.1 │
|
||||||
|
│ MUI 6 │ Gunicorn │
|
||||||
|
│ TypeScript │ Python 3.13 │
|
||||||
|
│ Port 3000 │ Port 5000 │
|
||||||
|
│ │ │
|
||||||
|
│ 7 pages │ 11 API endpoints │
|
||||||
|
│ Dark theme │ 4 lottery scrapers │
|
||||||
|
│ Axios client │ TTL cache (6h) │
|
||||||
|
│ │ 51 state tax rates │
|
||||||
|
│ │ Playwright (Canadian) │
|
||||||
|
└─────────────────┴───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Backend**: Python 3.13, Flask 3.1, Gunicorn, BeautifulSoup, Playwright
|
||||||
|
- **Frontend**: Next.js 15, TypeScript, Material-UI 6, Recharts, Axios
|
||||||
|
- **Testing**: pytest (64 tests), ruff (lint)
|
||||||
|
- **Docker**: Multi-container (backend + frontend + nginx for prod)
|
||||||
|
- **Config**: Environment variables via `.env` + `python-dotenv`
|
||||||
Reference in New Issue
Block a user