Version 1.1

This commit is contained in:
2026-02-18 08:24:54 -05:00
parent 4318c8f642
commit fdba869a8d
33 changed files with 2142 additions and 1942 deletions

View File

@@ -6,6 +6,7 @@ __pycache__/
.Python
env/
venv/
.venv/
ENV/
*.egg-info/
.pytest_cache/
@@ -13,6 +14,7 @@ ENV/
# Node
node_modules/
frontend/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

35
.env.example Normal file
View 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
View File

@@ -23,3 +23,23 @@ Thumbs.db
# Node
node_modules/
.next/
out/
# Data files
*.xlsx
*.xls
*.csv
# Debug HTML
*.html
# Docker
ssl/*.pem
ssl/*.key
ssl/*.crt
# Coverage
htmlcov/
.coverage
coverage.xml

View File

@@ -64,13 +64,13 @@ Model: Auto (override to the strongest available for high-stakes diffs).
- Prefer deterministic, reproducible steps.
- Cite sources when generating documents from a knowledge base.
## Repo facts (fill these in)
- Primary stack:
- Package manager:
- Test command:
- Lint/format command:
- Build command (if any):
- Deployment (if any):
## Repo facts
- Primary stack: Python 3.13 (Flask 3.1) + TypeScript (Next.js 15, MUI 6)
- Package manager: pip (backend) + npm (frontend)
- Test command: `pytest -q`
- Lint/format command: `ruff check . --fix`
- Build command (if any): `docker compose build`
- Deployment (if any): `docker compose -f docker-compose.prod.yml up -d`
## Claude Code Agents (optional)

View File

@@ -1,255 +1,92 @@
# 🐋 Docker Setup Complete!
# Docker Quick Start
## What's Been Created
## What's Included
### Docker Files
- `Dockerfile.backend` - Flask API with Playwright
- `Dockerfile.frontend` - Next.js app optimized for production
- `Dockerfile.email` - Email scheduler service
- `docker-compose.yml` - Development setup
- `docker-compose.prod.yml` - Production setup with nginx
- `.dockerignore` - Optimized build context
-`requirements.txt` - Python dependencies
### Configuration
- ✅ Updated `next.config.ts` for standalone output
- ✅ Created startup scripts (Windows & Linux)
- ✅ Complete documentation in `DOCKER_README.md`
- `Dockerfile.backend` Flask API with Playwright & Chromium
- `Dockerfile.frontend` Next.js standalone production build
- `docker-compose.yml` — Development setup (backend + frontend)
- `docker-compose.prod.yml` — Production setup (+ nginx reverse proxy)
- `nginx.conf` — Reverse proxy with rate limiting & caching
- `.env.example` — All available environment variables
---
## 🚀 Quick Start
## Quick Start
### Option 1: Windows Script (Easiest)
### 1. Configure
```bash
cp .env.example .env
```
### 2. Start
```bash
# Windows
docker-start.bat
# Linux/macOS
./docker-start.sh
# Or directly
docker compose up -d
```
### Option 2: Docker Compose
```bash
docker-compose up -d
```
### Option 3: Manual Build
```bash
# Build
docker-compose build
# Start
docker-compose up -d
# Check status
docker-compose ps
```
---
## 📦 What Gets Deployed
### Backend Container
- Python 3.13
- Flask API on port 5000
- Playwright with Chromium browser
- Lottery scrapers for all 4 lotteries
- Investment calculator
- Health check endpoint
### Frontend Container
- Node.js 20
- Next.js standalone build
- Optimized production bundle
- Connects to backend API
### Email Container (Optional)
- Runs daily at 7:00 AM
- Sends lottery jackpot emails
- Uses same scraping logic
---
## 🌐 Access Points
After running `docker-compose up -d`:
### 3. Open
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:5000
- **Health Check**: http://localhost:5000/api/health
---
## 📊 Container Management
## What Gets Deployed
### Backend Container
- Python 3.13 + Flask + Gunicorn (2 workers)
- Playwright with Chromium (for Canadian lottery scraping)
- Unified scraper for Powerball, Mega Millions, Lotto Max, Lotto 6/49
- Investment calculator with tax, annuity, group play, break-even
- TTL-cached jackpot data (6 hours default)
### Frontend Container
- Node.js 20 + Next.js 15 standalone build
- Material-UI dark theme
- 6 interactive pages (Calculator, Compare, Break-Even, Annuity, Group Play, Odds)
### Nginx (Production Only)
- Reverse proxy for API and frontend
- Rate limiting (10 req/s burst 20)
- Static asset caching (30 days)
- HTTPS ready (see `ssl/README.md`)
---
## Common Commands
### View Logs
```bash
# All services
docker-compose logs -f
# View logs
docker compose logs -f
# Specific service
docker-compose logs -f backend
docker-compose logs -f frontend
```
# Restart
docker compose restart
### Restart Services
```bash
docker-compose restart
```
# Rebuild after code changes
docker compose up -d --build
### Stop Everything
```bash
docker-compose down
```
# Stop
docker compose down
### Rebuild After Changes
```bash
docker-compose up -d --build
# Production
docker compose -f docker-compose.prod.yml up -d
```
---
## 🔧 Troubleshooting
## Troubleshooting
### Port Already in Use
If ports 3000 or 5000 are busy:
1. **Check Docker Desktop is running**
2. **Ensure ports 3000 and 5000 are available**
3. **Check logs**: `docker compose logs`
4. **Rebuild**: `docker compose up -d --build`
5. **Reset**: `docker compose down && docker compose up -d`
**Option A**: Stop other services
```bash
# Windows
netstat -ano | findstr :3000
taskkill /PID <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! 🎰🐋
See `DOCKER_README.md` for detailed troubleshooting.

View File

@@ -1,54 +1,56 @@
# Lottery Investment Calculator - Docker Setup
## 🐋 Docker Deployment Guide
### Prerequisites
## Prerequisites
- Docker Desktop installed (https://www.docker.com/products/docker-desktop)
- Docker Compose (included with Docker Desktop)
---
## 🚀 Quick Start
## Quick Start
### 1. Build and Run Everything
### 1. Configure Environment
```bash
docker-compose up -d
cp .env.example .env
# Edit .env with your preferred settings
```
This will start:
### 2. Build and Run
```bash
docker compose up -d
```
This starts:
- **Backend API** on http://localhost:5000
- **Frontend Web App** on http://localhost:3000
### 2. Check Status
### 3. Check Status
```bash
docker-compose ps
docker compose ps
```
### 3. View Logs
### 4. View Logs
```bash
# All services
docker-compose logs -f
docker compose logs -f
# Just backend
docker-compose logs -f backend
# Just frontend
docker-compose logs -f frontend
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### 4. Stop Everything
### 5. Stop
```bash
docker-compose down
docker compose down
```
---
## 📦 Individual Services
## Individual Services
### Backend Only
```bash
docker build -f Dockerfile.backend -t lottery-backend .
docker run -p 5000:5000 lottery-backend
docker run -p 5000:5000 --env-file .env lottery-backend
```
### Frontend Only
@@ -57,291 +59,139 @@ docker build -f Dockerfile.frontend -t lottery-frontend .
docker run -p 3000:3000 lottery-frontend
```
### Email Scheduler (Optional)
```bash
docker-compose --profile email up -d
```
---
## Configuration
All configuration is done via environment variables. See `.env.example` for available options.
Key variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_DEBUG` | `false` | Enable Flask debug mode |
| `FLASK_CORS_ORIGINS` | `*` | Allowed CORS origins |
| `FEDERAL_TAX_RATE` | `0.37` | US federal tax rate |
| `DEFAULT_STATE_TAX_RATE` | `0.055` | Default state tax rate |
| `USD_TO_CAD` | `1.44` | USD→CAD exchange rate |
| `CACHE_TTL_HOURS` | `6` | Jackpot cache duration |
---
## 🔧 Configuration
## Network
### Update Next.js to use standalone output
Add to `frontend/next.config.ts`:
```typescript
const nextConfig = {
output: 'standalone',
};
```
### Environment Variables
Create `.env` file:
```bash
# Backend
FLASK_ENV=production
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:5000
# Email (optional)
EMAIL_SENDER=mblanke@gmail.com
EMAIL_RECIPIENT=mblanke@gmail.com
EMAIL_PASSWORD=vyapvyjjfrqpqnax
```
Then update `docker-compose.yml` to use env_file:
```yaml
services:
backend:
env_file: .env
```
---
## 🏗️ Build Process
### First Time Setup
```bash
# Build all images
docker-compose build
# Or build individually
docker-compose build backend
docker-compose build frontend
docker-compose build email-scheduler
```
### Rebuild After Code Changes
```bash
# Rebuild and restart
docker-compose up -d --build
# Rebuild specific service
docker-compose up -d --build backend
```
---
## 🌐 Network Configuration
All services communicate via the `lottery-network` bridge network.
### Internal URLs (container to container):
### Internal URLs (container to container)
- Backend: `http://backend:5000`
- Frontend: `http://frontend:3000`
### External URLs (host to container):
### External URLs (host machine)
- Backend: `http://localhost:5000`
- Frontend: `http://localhost:3000`
---
## 📊 Health Checks
## Health Checks
The backend includes a health check endpoint:
```bash
curl http://localhost:5000/api/health
```
Check in Docker:
```bash
docker inspect lottery-backend | grep -A 10 Health
```
---
## 🔄 Production Deployment
## Production Deployment
### Docker Hub
### Using Production Compose
```bash
# Tag images
docker compose -f docker-compose.prod.yml up -d
```
This adds nginx reverse proxy with:
- Rate limiting (10 req/s)
- Static asset caching
- HTTPS support (configure certs in `ssl/`)
- Resource limits per container
See `ssl/README.md` for certificate setup.
### Deploy to Server
```bash
git clone <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-frontend yourusername/lottery-frontend:latest
# Push to Docker Hub
docker push yourusername/lottery-backend:latest
docker push yourusername/lottery-frontend:latest
```
### Deploy to Server
```bash
# Pull images on server
docker pull yourusername/lottery-backend:latest
docker pull yourusername/lottery-frontend:latest
# Run with compose
docker-compose -f docker-compose.prod.yml up -d
```
---
## 🐛 Troubleshooting
## Troubleshooting
### Backend won't start
### Port Already in Use
```bash
# Check logs
docker logs lottery-backend
# Windows
netstat -ano | findstr :5000
taskkill /PID <PID> /F
# Common issues:
# - Port 5000 already in use
# - Playwright installation failed
# - Missing dependencies
# Or change ports in docker-compose.yml
```
### Frontend can't connect to backend
### Backend Won't Start
```bash
# Check if backend is running
docker-compose ps
docker logs lottery-backend
docker compose build --no-cache backend
```
# Test backend directly
### Frontend Can't Connect to Backend
```bash
docker compose ps
curl http://localhost:5000/api/health
# Check frontend environment
docker exec lottery-frontend env | grep API_URL
```
### Playwright browser issues
### Playwright Browser Issues
```bash
# Rebuild with no cache
docker-compose build --no-cache backend
# Check Playwright installation
docker compose build --no-cache backend
docker exec lottery-backend playwright --version
```
### Container keeps restarting
### Access Container Shell
```bash
# View logs
docker logs lottery-backend --tail 100
docker exec -it lottery-backend /bin/bash
docker exec -it lottery-frontend /bin/sh
```
# Check health status
docker inspect lottery-backend | grep -A 5 Health
### Clean Everything
```bash
docker compose down -v --rmi all
docker system prune -a
```
---
## 📝 Useful Commands
## Resource Usage
### Access Container Shell
```bash
# Backend
docker exec -it lottery-backend /bin/bash
Production limits (set in `docker-compose.prod.yml`):
- Backend: 2 GB RAM, 1 CPU
- Frontend: 512 MB RAM, 0.5 CPU
- Nginx: 256 MB RAM, 0.25 CPU
# Frontend
docker exec -it lottery-frontend /bin/sh
```
### Remove Everything
```bash
# Stop and remove containers, networks
docker-compose down
# Also remove volumes
docker-compose down -v
# Remove images
docker-compose down --rmi all
```
### Prune Unused Resources
```bash
docker system prune -a
```
### View Resource Usage
### Monitor
```bash
docker stats
```
---
## 🚢 Alternative: Docker without Compose
## Image Sizes (Approximate)
### Create Network
```bash
docker network create lottery-network
```
### Run Backend
```bash
docker run -d \
--name lottery-backend \
--network lottery-network \
-p 5000:5000 \
lottery-backend
```
### Run Frontend
```bash
docker run -d \
--name lottery-frontend \
--network lottery-network \
-p 3000:3000 \
-e NEXT_PUBLIC_API_URL=http://localhost:5000 \
lottery-frontend
```
---
## 🎯 Email Scheduler with Docker
To include the email scheduler:
1. **Start with email service:**
```bash
docker-compose --profile email up -d
```
2. **Or add to default profile** (edit docker-compose.yml):
Remove `profiles: - email` from email-scheduler service
3. **Check email logs:**
```bash
docker logs lottery-email -f
```
---
## 🔐 Security Notes
⚠️ **Important:**
- Never commit `.env` files with real credentials
- Use Docker secrets in production
- Set proper firewall rules
- Use HTTPS in production
- Regularly update base images
---
## 📈 Scaling
### Run multiple backend instances
```bash
docker-compose up -d --scale backend=3
```
### Add load balancer (nginx)
See `docker-compose.prod.yml` for nginx configuration
---
## 🆘 Support
If containers won't start:
1. Check Docker Desktop is running
2. Ensure ports 3000 and 5000 are available
3. Check logs: `docker-compose logs`
4. Rebuild: `docker-compose up -d --build`
5. Reset: `docker-compose down && docker-compose up -d`
---
## 📦 Image Sizes (Approximate)
- Backend: ~1.5 GB (includes Chromium browser)
- Frontend: ~200 MB
- Email Scheduler: ~1.5 GB (includes Chromium browser)
To reduce size, consider multi-stage builds or Alpine Linux variants.
- Backend: ~1.5 GB (includes Chromium for Playwright)
- Frontend: ~200 MB (Next.js standalone)
- Nginx: ~30 MB

View File

@@ -36,14 +36,14 @@ COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
# Install Playwright Chromium (deps already installed above)
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy application files
COPY app.py .
COPY lottery_calculator.py .
COPY ["import requests.py", "."]
COPY config.py .
COPY scrapers.py .
# Expose port
EXPOSE 5000
@@ -51,7 +51,8 @@ EXPOSE 5000
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV FLASK_DEBUG=false
ENV PYTHONUNBUFFERED=1
# Run the application
CMD ["python", "app.py"]
# Run with gunicorn for production
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]

View File

@@ -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"]

View File

@@ -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

View File

@@ -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
View File

@@ -1,172 +1,443 @@
"""
Flask Backend for Lottery Investment Calculator
Provides API endpoints for jackpots and investment calculations
Flask Backend for Lottery Investment Calculator.
API endpoints for jackpots, investment calculations, comparisons,
break-even analysis, annuity projections, and state tax information.
"""
from __future__ import annotations
import logging
from flask import Flask, jsonify, request
from flask_cors import CORS
import requests
from bs4 import BeautifulSoup
import urllib3
from playwright.sync_api import sync_playwright
import re
from lottery_calculator import calculate_us_lottery, calculate_canadian_lottery
# Suppress SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
from config import (
ANNUITY_ANNUAL_INCREASE,
ANNUITY_YEARS,
LOTTERY_ODDS,
STATE_TAX_RATES,
load_config,
)
from lottery_calculator import (
calculate_annuity,
calculate_break_even,
calculate_canadian_lottery,
calculate_group_split,
calculate_us_lottery,
)
from scrapers import clear_cache, get_all_jackpots
app = Flask(__name__)
CORS(app) # Enable CORS for Next.js frontend
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# Common headers to mimic a browser request
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Cache-Control": "max-age=0",
}
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def get_us_lotteries():
"""Fetch Powerball and Mega Millions jackpots"""
results = {"Powerball": None, "Mega Millions": None}
# Powerball
try:
resp = requests.get("https://www.lotto.net/powerball", timeout=10, verify=False, headers=HEADERS)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
all_text = soup.get_text()
lines = all_text.split('\n')
for i, line in enumerate(lines):
if 'Next Jackpot' in line and i + 1 < len(lines):
next_line = lines[i + 1].strip()
if '$' in next_line and 'Million' in next_line:
# Extract numeric value
match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', next_line)
if match:
value = float(match.group(1).replace(',', ''))
results["Powerball"] = value * 1_000_000
break
except Exception as e:
print(f"Error fetching Powerball: {e}")
# Mega Millions
try:
resp = requests.get("https://www.lotto.net/mega-millions", timeout=10, verify=False, headers=HEADERS)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
all_text = soup.get_text()
lines = all_text.split('\n')
for i, line in enumerate(lines):
if 'Next Jackpot' in line and i + 1 < len(lines):
next_line = lines[i + 1].strip()
if '$' in next_line and 'Million' in next_line:
# Extract numeric value
match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', next_line)
if match:
value = float(match.group(1).replace(',', ''))
results["Mega Millions"] = value * 1_000_000
break
except Exception as e:
print(f"Error fetching Mega Millions: {e}")
return results
def create_app() -> Flask:
"""Application factory — creates and configures the Flask app."""
cfg = load_config()
app = Flask(__name__)
def get_canadian_lotteries():
"""Fetch Lotto Max and Lotto 6/49 jackpots using Playwright"""
results = {"Lotto Max": None, "Lotto 6/49": None}
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://www.olg.ca/", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(3000)
content = page.content()
browser.close()
# Lotto Max
lotto_max_match = re.search(r'LOTTO\s*MAX(?:(?!LOTTO\s*6/49).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
if lotto_max_match:
value = float(lotto_max_match.group(1).replace(',', ''))
results["Lotto Max"] = value * 1_000_000
# Lotto 6/49
lotto_649_match = re.search(r'LOTTO\s*6/49(?:(?!LOTTO\s*MAX).)*?\$\s*([\d.,]+)\s*Million', content, re.IGNORECASE | re.DOTALL)
if lotto_649_match:
value = float(lotto_649_match.group(1).replace(',', ''))
results["Lotto 6/49"] = value * 1_000_000
except Exception as e:
print(f"Error fetching Canadian lotteries: {e}")
return results
# CORS — restrict origins via env or allow all in dev
if cfg.allowed_origins == "*":
CORS(app)
else:
CORS(app, origins=[o.strip() for o in cfg.allowed_origins.split(",")])
# ------------------------------------------------------------------
# Validation helpers
# ------------------------------------------------------------------
@app.route('/api/jackpots', methods=['GET'])
def get_jackpots():
"""API endpoint to get all lottery jackpots"""
us_lotteries = get_us_lotteries()
canadian_lotteries = get_canadian_lotteries()
return jsonify({
"us": {
"powerball": us_lotteries["Powerball"],
"megaMillions": us_lotteries["Mega Millions"]
},
"canadian": {
"lottoMax": canadian_lotteries["Lotto Max"],
"lotto649": canadian_lotteries["Lotto 6/49"]
def _require_json() -> dict | None:
"""Parse JSON body or return None."""
return request.get_json(silent=True)
def _validate_number(
value, name: str, *, minimum: float = 0, maximum: float | None = None
) -> float | None:
"""Coerce *value* to float and validate range. Returns None on bad input."""
try:
v = float(value)
except (TypeError, ValueError):
return None
if v < minimum:
return None
if maximum is not None and v > maximum:
return None
return v
# ------------------------------------------------------------------
# Jackpot endpoints
# ------------------------------------------------------------------
@app.route("/api/jackpots", methods=["GET"])
def get_jackpots():
"""Return current jackpots for all four lotteries (cached)."""
force = request.args.get("refresh", "").lower() in ("1", "true")
data = get_all_jackpots(force_refresh=force)
return jsonify(data)
@app.route("/api/jackpots/refresh", methods=["POST"])
def refresh_jackpots():
"""Force-refresh the jackpot cache and return new values."""
clear_cache()
data = get_all_jackpots(force_refresh=True)
return jsonify(data)
# ------------------------------------------------------------------
# Calculator endpoints
# ------------------------------------------------------------------
@app.route("/api/calculate", methods=["POST"])
def calculate():
"""Calculate investment returns for a given jackpot."""
data = _require_json()
if data is None:
return jsonify({"error": "Request body must be JSON"}), 400
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
if jackpot is None:
return jsonify({"error": "jackpot must be a positive number"}), 400
lottery_type = data.get("type", "us")
if lottery_type not in ("us", "canadian"):
return jsonify({"error": "type must be 'us' or 'canadian'"}), 400
invest_pct = _validate_number(
data.get("investPercentage", cfg.investment.invest_percentage),
"investPercentage",
minimum=0,
maximum=1,
)
annual_return = _validate_number(
data.get("annualReturn", cfg.investment.annual_return),
"annualReturn",
minimum=0,
maximum=1,
)
cycles = _validate_number(
data.get("cycles", cfg.investment.cycles),
"cycles",
minimum=1,
maximum=100,
)
# State tax for US calculations
state_code = data.get("state")
state_tax = cfg.tax.default_state_tax_rate
if lottery_type == "us" and state_code:
state_info = STATE_TAX_RATES.get(state_code.upper())
if state_info:
state_tax = state_info["rate"]
if invest_pct is None or annual_return is None or cycles is None:
return jsonify({"error": "Invalid parameter values"}), 400
try:
if lottery_type == "us":
result = calculate_us_lottery(
jackpot,
invest_percentage=invest_pct,
annual_return=annual_return,
cycles=int(cycles),
state_tax_rate=state_tax,
lump_sum_rate=cfg.tax.lump_sum_rate,
federal_tax_rate=cfg.tax.federal_tax_rate,
usd_cad_rate=cfg.tax.usd_cad_rate,
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
)
else:
result = calculate_canadian_lottery(
jackpot,
invest_percentage=invest_pct,
annual_return=annual_return,
cycles=int(cycles),
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
)
return jsonify(result)
except Exception:
logger.exception("Calculation error")
return jsonify({"error": "Internal calculation error"}), 500
# ------------------------------------------------------------------
# State tax endpoints
# ------------------------------------------------------------------
@app.route("/api/states", methods=["GET"])
def get_states():
"""Return all US states with their lottery tax rates."""
states = [
{"code": code, "name": info["name"], "rate": info["rate"]}
for code, info in sorted(STATE_TAX_RATES.items())
]
return jsonify(states)
@app.route("/api/states/<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'])
def calculate():
"""API endpoint to calculate investment returns"""
data = request.json
jackpot = data.get('jackpot')
lottery_type = data.get('type', 'us') # 'us' or 'canadian'
invest_percentage = data.get('investPercentage', 0.90)
annual_return = data.get('annualReturn', 0.045)
cycles = data.get('cycles', 8)
if not jackpot:
return jsonify({"error": "Jackpot amount is required"}), 400
try:
if lottery_type == 'us':
result = calculate_us_lottery(jackpot, invest_percentage, annual_return, cycles)
else:
result = calculate_canadian_lottery(jackpot, invest_percentage, annual_return, cycles)
if amount and amount > 0:
try:
if country_type == "us":
calc = calculate_us_lottery(
amount,
state_tax_rate=state_tax,
lump_sum_rate=cfg.tax.lump_sum_rate,
federal_tax_rate=cfg.tax.federal_tax_rate,
usd_cad_rate=cfg.tax.usd_cad_rate,
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
)
else:
calc = calculate_canadian_lottery(
amount,
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
)
entry["calculation"] = calc
except Exception:
logger.exception("Comparison calc failed for %s", key)
comparisons.append(entry)
return jsonify(comparisons)
# ------------------------------------------------------------------
# Break-even calculator
# ------------------------------------------------------------------
@app.route("/api/calculate/breakeven", methods=["POST"])
def break_even():
"""Calculate the jackpot amount where expected value equals ticket cost."""
data = _require_json()
if data is None:
return jsonify({"error": "Request body must be JSON"}), 400
lottery_key = data.get("lottery", "powerball")
odds_info = LOTTERY_ODDS.get(lottery_key)
if not odds_info:
return jsonify({"error": f"Unknown lottery: {lottery_key}"}), 400
ticket_cost = _validate_number(
data.get("ticketCost", odds_info["ticket_cost"]),
"ticketCost",
minimum=0.01,
)
state_code = data.get("state")
state_tax = cfg.tax.default_state_tax_rate
if state_code:
st = STATE_TAX_RATES.get(state_code.upper())
if st:
state_tax = st["rate"]
result = calculate_break_even(
odds=odds_info["odds"],
ticket_cost=ticket_cost,
country=odds_info["country"],
lump_sum_rate=cfg.tax.lump_sum_rate,
federal_tax_rate=cfg.tax.federal_tax_rate,
state_tax_rate=state_tax,
)
result["lottery"] = odds_info["name"]
result["oddsFormatted"] = f"1 in {odds_info['odds']:,}"
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
# ------------------------------------------------------------------
# Annuity calculator
# ------------------------------------------------------------------
@app.route("/api/calculate/annuity", methods=["POST"])
def annuity():
"""Calculate 30-year annuity payout schedule."""
data = _require_json()
if data is None:
return jsonify({"error": "Request body must be JSON"}), 400
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
if jackpot is None:
return jsonify({"error": "jackpot must be a positive number"}), 400
lottery_type = data.get("type", "us")
state_code = data.get("state")
state_tax = cfg.tax.default_state_tax_rate
if state_code:
st = STATE_TAX_RATES.get(state_code.upper())
if st:
state_tax = st["rate"]
years = int(
_validate_number(
data.get("years", ANNUITY_YEARS), "years", minimum=1, maximum=40
)
or ANNUITY_YEARS
)
annual_increase = (
_validate_number(
data.get("annualIncrease", ANNUITY_ANNUAL_INCREASE),
"annualIncrease",
minimum=0,
maximum=0.20,
)
or ANNUITY_ANNUAL_INCREASE
)
result = calculate_annuity(
jackpot=jackpot,
country=lottery_type,
years=years,
annual_increase=annual_increase,
federal_tax_rate=cfg.tax.federal_tax_rate,
state_tax_rate=state_tax,
)
return jsonify(result)
# ------------------------------------------------------------------
# Group play calculator
# ------------------------------------------------------------------
@app.route("/api/calculate/group", methods=["POST"])
def group_play():
"""Split winnings among a group with optional custom shares."""
data = _require_json()
if data is None:
return jsonify({"error": "Request body must be JSON"}), 400
jackpot = _validate_number(data.get("jackpot"), "jackpot", minimum=1)
if jackpot is None:
return jsonify({"error": "jackpot must be a positive number"}), 400
members_val = _validate_number(
data.get("members", 2), "members", minimum=1, maximum=1000
)
members = int(members_val) if members_val else 2
shares = data.get("shares") # optional list of floats summing to 1.0
lottery_type = data.get("type", "us")
state_code = data.get("state")
state_tax = cfg.tax.default_state_tax_rate
if state_code:
st = STATE_TAX_RATES.get(state_code.upper())
if st:
state_tax = st["rate"]
result = calculate_group_split(
jackpot=jackpot,
members=members,
shares=shares,
country=lottery_type,
lump_sum_rate=cfg.tax.lump_sum_rate,
federal_tax_rate=cfg.tax.federal_tax_rate,
state_tax_rate=state_tax,
usd_cad_rate=cfg.tax.usd_cad_rate,
investment_income_tax_rate=cfg.tax.investment_income_tax_rate,
personal_withdrawal_pct=cfg.tax.personal_withdrawal_pct,
)
return jsonify(result)
# ------------------------------------------------------------------
# Odds / probability info
# ------------------------------------------------------------------
@app.route("/api/odds", methods=["GET"])
def get_odds():
"""Return odds and ticket cost for all supported lotteries."""
result = []
for key, info in LOTTERY_ODDS.items():
result.append(
{
"key": key,
"name": info["name"],
"odds": info["odds"],
"oddsFormatted": f"1 in {info['odds']:,}",
"oddsPercentage": f"{(1 / info['odds']) * 100:.10f}%",
"ticketCost": info["ticket_cost"],
"country": info["country"],
}
)
return jsonify(result)
# ------------------------------------------------------------------
# Health check
# ------------------------------------------------------------------
@app.route("/api/health", methods=["GET"])
def health():
"""Health check endpoint."""
return jsonify({"status": "ok", "version": "2.0.0"})
return app
@app.route('/api/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
app = create_app()
if __name__ == '__main__':
print("🎰 Lottery Investment Calculator API")
print("=" * 50)
print("Starting Flask server on http://localhost:5000")
print("API Endpoints:")
print(" - GET /api/jackpots - Get current jackpots")
print(" - POST /api/calculate - Calculate investments")
print(" - GET /api/health - Health check")
print("=" * 50)
# Bind to 0.0.0.0 so the Flask app is reachable from outside the container
app.run(debug=True, host='0.0.0.0', port=5000)
if __name__ == "__main__":
cfg = load_config()
logger.info("Lottery Investment Calculator API v2.0")
logger.info("Endpoints:")
logger.info(" GET /api/jackpots - Current jackpots (cached)")
logger.info(" POST /api/jackpots/refresh - Force refresh")
logger.info(" POST /api/calculate - Investment calculator")
logger.info(" POST /api/calculate/breakeven - Break-even calculator")
logger.info(" POST /api/calculate/annuity - Annuity calculator")
logger.info(" POST /api/calculate/group - Group play calculator")
logger.info(" GET /api/compare - Side-by-side comparison")
logger.info(" GET /api/states - US state tax rates")
logger.info(" GET /api/odds - Lottery odds info")
logger.info(" GET /api/health - Health check")
app.run(debug=cfg.debug, host=cfg.host, port=cfg.port)

212
config.py Normal file
View 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),
)

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# Nginx Reverse Proxy
nginx:
@@ -26,14 +24,17 @@ services:
container_name: lottery-backend
expose:
- "5000"
env_file:
- .env
environment:
- FLASK_ENV=production
- FLASK_DEBUG=false
- PYTHONUNBUFFERED=1
restart: always
networks:
- lottery-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
@@ -41,10 +42,10 @@ services:
deploy:
resources:
limits:
cpus: '1'
cpus: "1"
memory: 2G
reservations:
cpus: '0.5'
cpus: "0.5"
memory: 1G
# Next.js Frontend
@@ -52,11 +53,13 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
NEXT_PUBLIC_API_URL: /api
container_name: lottery-frontend
expose:
- "3000"
environment:
- NEXT_PUBLIC_API_URL=http://backend:5000
- NEXT_PUBLIC_API_URL=/api
- NODE_ENV=production
depends_on:
- backend
@@ -66,29 +69,12 @@ services:
deploy:
resources:
limits:
cpus: '0.5'
cpus: "0.5"
memory: 512M
reservations:
cpus: '0.25'
cpus: "0.25"
memory: 256M
# Email Scheduler
email-scheduler:
build:
context: .
dockerfile: Dockerfile.email
container_name: lottery-email
environment:
- PYTHONUNBUFFERED=1
restart: always
networks:
- lottery-network
deploy:
resources:
limits:
cpus: '0.5'
memory: 1G
networks:
lottery-network:
driver: bridge

View File

@@ -9,14 +9,17 @@ services:
container_name: lottery-backend
ports:
- "5000:5000"
env_file:
- .env
environment:
- FLASK_ENV=production
- FLASK_DEBUG=false
- PYTHONUNBUFFERED=1
restart: unless-stopped
networks:
- lottery-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
@@ -40,20 +43,6 @@ services:
networks:
- lottery-network
# Email Scheduler (Optional - runs daily at 7 AM)
email-scheduler:
build:
context: .
dockerfile: Dockerfile.email
container_name: lottery-email
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
networks:
- lottery-network
profiles:
- email # Only start if explicitly requested
networks:
lottery-network:
driver: bridge

View File

@@ -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()

View File

@@ -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']}")

View File

@@ -1,195 +1,344 @@
"""
Lottery Investment Calculator
Handles both US and Canadian lottery calculations
Lottery Investment Calculator — pure calculation logic.
All functions are deterministic and side-effect free.
Tax rates, exchange rates, and investment defaults are passed as explicit
parameters (with sensible defaults) so that callers can override via config.
"""
def calculate_us_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8):
from __future__ import annotations
# ---------------------------------------------------------------------------
# Core calculators
# ---------------------------------------------------------------------------
def _run_investment_cycles(
principal: float,
annual_return: float,
cycles: int,
investment_income_tax_rate: float,
personal_withdrawal_pct: float,
) -> tuple[list[dict], float, float]:
"""Simulate 90-day reinvestment cycles.
Returns:
(cycle_results, total_personal_withdrawals, final_principal)
"""
Calculate investment returns for US lottery winnings
cycle_results: list[dict] = []
total_personal_withdrawals = 0.0
for cycle in range(1, cycles + 1):
interest_earned = principal * annual_return * (90 / 365)
taxes_owed = interest_earned * investment_income_tax_rate
personal_withdrawal = interest_earned * personal_withdrawal_pct
total_withdrawal = taxes_owed + personal_withdrawal
reinvestment = interest_earned - total_withdrawal
new_principal = principal + reinvestment
total_personal_withdrawals += personal_withdrawal
cycle_results.append(
{
"cycle": cycle,
"principalStart": principal,
"interestEarned": interest_earned,
"taxesOwed": taxes_owed,
"personalWithdrawal": personal_withdrawal,
"totalWithdrawal": total_withdrawal,
"reinvestment": reinvestment,
"principalEnd": new_principal,
}
)
principal = new_principal
return cycle_results, total_personal_withdrawals, principal
def calculate_us_lottery(
jackpot: float,
invest_percentage: float = 0.90,
annual_return: float = 0.045,
cycles: int = 8,
*,
state_tax_rate: float = 0.055,
lump_sum_rate: float = 0.52,
federal_tax_rate: float = 0.37,
usd_cad_rate: float = 1.35,
investment_income_tax_rate: float = 0.5353,
personal_withdrawal_pct: float = 0.10,
) -> dict:
"""Calculate investment returns for US lottery winnings.
Args:
jackpot: Original jackpot amount (USD)
invest_percentage: Percentage to invest (default 90%)
annual_return: Annual return rate (default 4.5%)
cycles: Number of 90-day cycles to calculate (default 8)
jackpot: Advertised jackpot amount (USD).
invest_percentage: Fraction to invest (0-1).
annual_return: Expected annual return rate.
cycles: Number of 90-day reinvestment cycles.
state_tax_rate: State income-tax rate on winnings.
lump_sum_rate: Fraction of advertised jackpot available as lump sum.
federal_tax_rate: Federal income-tax rate on winnings.
usd_cad_rate: USD to CAD exchange rate.
investment_income_tax_rate: Marginal tax on investment income.
personal_withdrawal_pct: Fraction of interest withdrawn each cycle.
"""
# US Lottery calculations
cash_sum = jackpot * 0.52 # Lump sum is 52%
federal_tax = cash_sum * 0.37
state_tax = cash_sum * 0.055
cash_sum = jackpot * lump_sum_rate
federal_tax = cash_sum * federal_tax_rate
state_tax = cash_sum * state_tax_rate
net_amount = cash_sum - federal_tax - state_tax
# Convert to Canadian dollars
canadian_amount = net_amount * 1.35
# Split into investment and fun money
canadian_amount = net_amount * usd_cad_rate
investment_principal = canadian_amount * invest_percentage
fun_money = canadian_amount * (1 - invest_percentage)
# Calculate cycles
cycle_results = []
principal = investment_principal
total_personal_withdrawals = 0
for cycle in range(1, cycles + 1):
# Interest for 90 days
interest_earned = principal * annual_return * (90/365)
# Taxes on investment income (53.53%)
taxes_owed = interest_earned * 0.5353
# Personal withdrawal (10% of interest)
personal_withdrawal = interest_earned * 0.10
# Total withdrawal
total_withdrawal = taxes_owed + personal_withdrawal
# Reinvestment
reinvestment = interest_earned - total_withdrawal
# New principal
new_principal = principal + reinvestment
total_personal_withdrawals += personal_withdrawal
cycle_results.append({
'cycle': cycle,
'principal_start': principal,
'interest_earned': interest_earned,
'taxes_owed': taxes_owed,
'personal_withdrawal': personal_withdrawal,
'total_withdrawal': total_withdrawal,
'reinvestment': reinvestment,
'principal_end': new_principal
})
principal = new_principal
# Calculate daily income
net_daily_income = (investment_principal * annual_return * 0.5353) / 365
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
investment_principal,
annual_return,
cycles,
investment_income_tax_rate,
personal_withdrawal_pct,
)
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365
return {
'country': 'US',
'original_jackpot': jackpot,
'cash_sum': cash_sum,
'federal_tax': federal_tax,
'state_tax': state_tax,
'net_amount_usd': net_amount,
'net_amount_cad': canadian_amount,
'investment_principal': investment_principal,
'fun_money': fun_money,
'net_daily_income': net_daily_income,
'annual_income': net_daily_income * 365,
'total_personal_withdrawals': total_personal_withdrawals,
'final_principal': principal,
'cycles': cycle_results
"country": "US",
"originalJackpot": jackpot,
"cashSum": cash_sum,
"federalTax": federal_tax,
"stateTax": state_tax,
"stateTaxRate": state_tax_rate,
"netAmountUsd": net_amount,
"netAmountCad": canadian_amount,
"investmentPrincipal": investment_principal,
"funMoney": fun_money,
"netDailyIncome": net_daily_income,
"annualIncome": net_daily_income * 365,
"totalPersonalWithdrawals": total_withdrawals,
"finalPrincipal": final_principal,
"cycles": cycle_results,
}
def calculate_canadian_lottery(jackpot, invest_percentage=0.90, annual_return=0.045, cycles=8):
"""
Calculate investment returns for Canadian lottery winnings
def calculate_canadian_lottery(
jackpot: float,
invest_percentage: float = 0.90,
annual_return: float = 0.045,
cycles: int = 8,
*,
investment_income_tax_rate: float = 0.5353,
personal_withdrawal_pct: float = 0.10,
) -> dict:
"""Calculate investment returns for Canadian lottery winnings (tax-free).
Args:
jackpot: Original jackpot amount (CAD) - TAX FREE!
invest_percentage: Percentage to invest (default 90%)
annual_return: Annual return rate (default 4.5%)
cycles: Number of 90-day cycles to calculate (default 8)
jackpot: Jackpot amount (CAD) - no tax deducted on winnings.
invest_percentage: Fraction to invest (0-1).
annual_return: Expected annual return rate.
cycles: Number of 90-day reinvestment cycles.
investment_income_tax_rate: Marginal tax on investment income.
personal_withdrawal_pct: Fraction of interest withdrawn each cycle.
"""
# Canadian lotteries - NO TAX on winnings!
net_amount = jackpot
# Split into investment and fun money
net_amount = jackpot # Tax-free!
investment_principal = net_amount * invest_percentage
fun_money = net_amount * (1 - invest_percentage)
# Calculate cycles
cycle_results = []
principal = investment_principal
total_personal_withdrawals = 0
for cycle in range(1, cycles + 1):
# Interest for 90 days
interest_earned = principal * annual_return * (90/365)
# Taxes on investment income (53.53%)
taxes_owed = interest_earned * 0.5353
# Personal withdrawal (10% of interest)
personal_withdrawal = interest_earned * 0.10
# Total withdrawal
total_withdrawal = taxes_owed + personal_withdrawal
# Reinvestment
reinvestment = interest_earned - total_withdrawal
# New principal
new_principal = principal + reinvestment
total_personal_withdrawals += personal_withdrawal
cycle_results.append({
'cycle': cycle,
'principal_start': principal,
'interest_earned': interest_earned,
'taxes_owed': taxes_owed,
'personal_withdrawal': personal_withdrawal,
'total_withdrawal': total_withdrawal,
'reinvestment': reinvestment,
'principal_end': new_principal
})
principal = new_principal
# Calculate daily income
net_daily_income = (investment_principal * annual_return * 0.5353) / 365
cycle_results, total_withdrawals, final_principal = _run_investment_cycles(
investment_principal,
annual_return,
cycles,
investment_income_tax_rate,
personal_withdrawal_pct,
)
net_daily_income = (investment_principal * annual_return * (1 - investment_income_tax_rate)) / 365
return {
'country': 'Canada',
'original_jackpot': jackpot,
'net_amount_cad': net_amount,
'investment_principal': investment_principal,
'fun_money': fun_money,
'net_daily_income': net_daily_income,
'annual_income': net_daily_income * 365,
'total_personal_withdrawals': total_personal_withdrawals,
'final_principal': principal,
'cycles': cycle_results
"country": "Canada",
"originalJackpot": jackpot,
"netAmountCad": net_amount,
"investmentPrincipal": investment_principal,
"funMoney": fun_money,
"netDailyIncome": net_daily_income,
"annualIncome": net_daily_income * 365,
"totalPersonalWithdrawals": total_withdrawals,
"finalPrincipal": final_principal,
"cycles": cycle_results,
}
if __name__ == "__main__":
# Test with current jackpots
print("=" * 80)
print("US LOTTERY - MEGA MILLIONS ($547M)")
print("=" * 80)
us_result = calculate_us_lottery(547_000_000)
print(f"Original Jackpot: ${us_result['original_jackpot']:,.0f}")
print(f"Cash Sum (52%): ${us_result['cash_sum']:,.0f}")
print(f"After Taxes (USD): ${us_result['net_amount_usd']:,.0f}")
print(f"After Taxes (CAD): ${us_result['net_amount_cad']:,.0f}")
print(f"Investment (90%): ${us_result['investment_principal']:,.0f}")
print(f"Fun Money (10%): ${us_result['fun_money']:,.0f}")
print(f"Daily Income: ${us_result['net_daily_income']:,.2f}")
print(f"Annual Income: ${us_result['annual_income']:,.2f}")
print(f"Final Principal (after 8 cycles): ${us_result['final_principal']:,.0f}")
print("\n" + "=" * 80)
print("CANADIAN LOTTERY - LOTTO 6/49 ($32M CAD)")
print("=" * 80)
can_result = calculate_canadian_lottery(32_000_000)
print(f"Original Jackpot (TAX FREE!): ${can_result['original_jackpot']:,.0f}")
print(f"Investment (90%): ${can_result['investment_principal']:,.0f}")
print(f"Fun Money (10%): ${can_result['fun_money']:,.0f}")
print(f"Daily Income: ${can_result['net_daily_income']:,.2f}")
print(f"Annual Income: ${can_result['annual_income']:,.2f}")
print(f"Final Principal (after 8 cycles): ${can_result['final_principal']:,.0f}")
print("\n" + "=" * 80)
print("COMPARISON")
print("=" * 80)
print(f"US ($547M) - You keep: ${us_result['net_amount_cad']:,.0f} CAD after taxes")
print(f"Canadian ($32M) - You keep: ${can_result['net_amount_cad']:,.0f} CAD (NO TAXES!)")
print(f"\nUS Daily Income: ${us_result['net_daily_income']:,.2f}")
print(f"Canadian Daily Income: ${can_result['net_daily_income']:,.2f}")
# ---------------------------------------------------------------------------
# Break-even calculator
# ---------------------------------------------------------------------------
def calculate_break_even(
odds: int,
ticket_cost: float,
country: str = "us",
*,
lump_sum_rate: float = 0.52,
federal_tax_rate: float = 0.37,
state_tax_rate: float = 0.055,
) -> dict:
"""Calculate the jackpot where expected value >= ticket cost.
For US lotteries the take-home fraction is::
lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
For Canadian lotteries the full jackpot is kept (tax-free).
Returns a dict with the break-even jackpot and supporting details.
"""
if country == "us":
take_home_fraction = lump_sum_rate * (1 - federal_tax_rate - state_tax_rate)
else:
take_home_fraction = 1.0
# EV = (jackpot * take_home_fraction) / odds >= ticket_cost
# => jackpot >= ticket_cost * odds / take_home_fraction
break_even_jackpot = (ticket_cost * odds) / take_home_fraction
probability = 1 / odds
return {
"breakEvenJackpot": break_even_jackpot,
"takeHomeFraction": take_home_fraction,
"odds": odds,
"probability": probability,
"ticketCost": ticket_cost,
"expectedValueAtBreakEven": probability * break_even_jackpot * take_home_fraction,
}
# ---------------------------------------------------------------------------
# Annuity calculator
# ---------------------------------------------------------------------------
def calculate_annuity(
jackpot: float,
country: str = "us",
years: int = 30,
annual_increase: float = 0.05,
*,
federal_tax_rate: float = 0.37,
state_tax_rate: float = 0.055,
) -> dict:
"""Calculate a multi-year annuity payout schedule.
Powerball / Mega Millions annuities pay an initial amount then increase
each year by *annual_increase* (typically 5%).
Returns yearly pre-tax and after-tax amounts plus totals.
"""
# Calculate initial annual payment using geometric series:
# jackpot = payment * sum((1 + r)^k) for k=0..years-1
# = payment * ((1+r)^years - 1) / r
if annual_increase > 0:
geo_sum = ((1 + annual_increase) ** years - 1) / annual_increase
else:
geo_sum = float(years)
initial_payment = jackpot / geo_sum
schedule: list[dict] = []
total_pre_tax = 0.0
total_after_tax = 0.0
for year in range(1, years + 1):
pre_tax = initial_payment * (1 + annual_increase) ** (year - 1)
tax = pre_tax * (federal_tax_rate + state_tax_rate) if country == "us" else 0.0
after_tax = pre_tax - tax
total_pre_tax += pre_tax
total_after_tax += after_tax
schedule.append(
{
"year": year,
"preTax": pre_tax,
"tax": tax,
"afterTax": after_tax,
}
)
return {
"jackpot": jackpot,
"country": country,
"years": years,
"annualIncrease": annual_increase,
"initialPayment": initial_payment,
"totalPreTax": total_pre_tax,
"totalAfterTax": total_after_tax,
"schedule": schedule,
}
# ---------------------------------------------------------------------------
# Group play calculator
# ---------------------------------------------------------------------------
def calculate_group_split(
jackpot: float,
members: int = 2,
shares: list[float] | None = None,
country: str = "us",
*,
lump_sum_rate: float = 0.52,
federal_tax_rate: float = 0.37,
state_tax_rate: float = 0.055,
usd_cad_rate: float = 1.35,
investment_income_tax_rate: float = 0.5353,
personal_withdrawal_pct: float = 0.10,
) -> dict:
"""Split lottery winnings among *members* with optional custom shares.
If *shares* is None every member gets an equal share. Otherwise *shares*
must be a list of floats summing to ~1.0 with length == *members*.
"""
# Normalise shares
if shares is None:
share_list = [1.0 / members] * members
else:
if len(shares) != members:
share_list = [1.0 / members] * members
else:
total = sum(shares)
share_list = [s / total for s in shares] if total > 0 else [1.0 / members] * members
member_results: list[dict] = []
for i, share in enumerate(share_list):
member_jackpot = jackpot * share
if country == "us":
calc = calculate_us_lottery(
member_jackpot,
lump_sum_rate=lump_sum_rate,
federal_tax_rate=federal_tax_rate,
state_tax_rate=state_tax_rate,
usd_cad_rate=usd_cad_rate,
investment_income_tax_rate=investment_income_tax_rate,
personal_withdrawal_pct=personal_withdrawal_pct,
)
else:
calc = calculate_canadian_lottery(
member_jackpot,
investment_income_tax_rate=investment_income_tax_rate,
personal_withdrawal_pct=personal_withdrawal_pct,
)
member_results.append(
{
"member": i + 1,
"share": share,
"jackpotShare": member_jackpot,
"calculation": calc,
}
)
return {
"originalJackpot": jackpot,
"members": members,
"shares": share_list,
"country": country,
"memberResults": member_results,
}

Binary file not shown.

77
nginx.conf Normal file
View 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
View 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"

View File

@@ -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
View 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

View File

@@ -1,9 +1,9 @@
flask
flask-cors
requests
beautifulsoup4
playwright
urllib3
openpyxl
pandas
schedule
flask==3.1.0
flask-cors==5.0.1
requests==2.32.3
beautifulsoup4==4.12.3
playwright==1.49.1
gunicorn==23.0.0
cachetools==5.5.1
python-dotenv==1.0.1
certifi==2024.12.14

191
scrapers.py Normal file
View 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

View File

@@ -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
View 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.

View File

@@ -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
View 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
View 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
View 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"

View 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
View 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