diff --git a/.env.example b/.env.example index dd3a071..f29e807 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,61 @@ -# Copy this file to .env and fill in your values -# NEVER commit your .env file to git +# ─────────────────────────────────────────────────────────────────────────── +# Sim2Sim — environment variables +# Copy this file to .env and fill in your values. NEVER commit .env to git. +# ─────────────────────────────────────────────────────────────────────────── -# Get your key at: https://console.anthropic.com +# ── Core ─────────────────────────────────────────────────────────────────── +# Anthropic API key for AI explanations. Get one at https://console.anthropic.com ANTHROPIC_API_KEY=sk-ant-your-key-here # Rate limiting (requests per minute per IP) RATE_LIMIT_PER_MINUTE=20 -# Set to "production" on Replit +# "development" relaxes CORS and bypasses Pro gating with the "dev" license key. +# Set to "production" on Replit. ENVIRONMENT=development + +# Required when ENVIRONMENT=production — comma-separated list of allowed +# origins (e.g. https://sim2sim.app,https://www.sim2sim.app) +ALLOWED_ORIGIN= + +# Public base URL of your deployment — used in license-key emails. +APP_BASE_URL=https://sim2sim.app + + +# ── Licensing ────────────────────────────────────────────────────────────── +# HMAC secret used to sign license keys. Generate a fresh one for production: +# python -c "import secrets; print(secrets.token_urlsafe(48))" +# Required in production; defaults to a dev-only value in development. +SIM2SIM_LICENSE_SECRET= + +# Path to the SQLite database holding licenses. Defaults to ./sim2sim.db. +# On Replit, the home directory persists, so the default is fine. +SIM2SIM_DB=sim2sim.db + + +# ── Stripe ───────────────────────────────────────────────────────────────── +# Stripe webhook signing secret — copy from the Stripe Dashboard: +# Developers → Webhooks → click your endpoint → Signing secret +STRIPE_WEBHOOK_SECRET=whsec_ + +# Price amounts in cents — used to map checkout sessions to a tier when +# you use Stripe Payment Links (which don't expose price IDs in the event). +# Set these to whatever you charge. +STRIPE_PRICE_PRO_CENTS=4900 +STRIPE_PRICE_TEAM_CENTS=24900 + +# Optional: hosted Payment Link URLs. After creating Payment Links in the +# Stripe Dashboard, paste them here. The pricing page reads these from +# window.SIM2SIM_CONFIG (injected at deploy time) — or you can hard-code +# them in static/pricing.html for a one-time launch. +STRIPE_PRO_PAYMENT_LINK=https://buy.stripe.com/__paste_pro_link__ +STRIPE_TEAM_PAYMENT_LINK=https://buy.stripe.com/__paste_team_link__ + + +# ── Email (Resend) ───────────────────────────────────────────────────────── +# Resend API key — get one at https://resend.com (free tier: 100/day) +RESEND_API_KEY=re_ + +# Default From address. Use "onboarding@resend.dev" while testing; switch +# to a verified sending domain ("hello@sim2sim.app") before launch. +RESEND_FROM=Sim2Sim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d2fd3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Lint (ruff) + run: ruff check src tests main.py + continue-on-error: true # don't fail the build on style; just surface findings + + - name: Run tests + env: + ANTHROPIC_API_KEY: "" # explainer tests don't need a real key + run: pytest -q diff --git a/.gitignore b/.gitignore index 79bd8dd..c3ff5ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ venv/ dist/ build/ .DS_Store + +# Billing DB (created at runtime — never commit) +sim2sim.db +sim2sim.db-journal +sim2sim.db-wal +sim2sim.db-shm diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..1b38ada --- /dev/null +++ b/LAUNCH.md @@ -0,0 +1,109 @@ +# Sim2Sim — Launch Checklist + +A 30-minute path from "freshly merged PR" to "taking real money". + +## 1. Buy your domain (5 min) + +Recommended: **sim2sim.app** ($14–20/yr) — short, on-brand, no aftermarket fee. +(`sim2sim.com` is parked at $5,000 — skip it for now.) + +Other strong options: +- `sim2sim.tools` — fits the product perfectly +- `getsim2sim.com` or `usesim2sim.com` — keeps you in `.com` for ~$12/yr +- `sim2sim.ai` — premium ($70–100/yr) but great if you want the AI angle + +Buy at: [Namecheap](https://namecheap.com), [Porkbun](https://porkbun.com), or [Cloudflare Registrar](https://cloudflare.com/products/registrar/) (no markup). + +## 2. Connect domain to Replit (5 min) + +1. In Replit: **Deployments → Settings → Custom Domains → Link domain**. +2. Replit shows you a CNAME / A record to add at your registrar. +3. Paste it into your registrar's DNS settings. Wait 5–10 min for propagation. + +## 3. Create Stripe Payment Links (5 min) + +In your Stripe dashboard: + +1. **Product → Add product** — "Sim2Sim Pro", one-time, $49.00. +2. **Product → Add product** — "Sim2Sim Team", one-time, $249.00. +3. Click each product → **Pricing → Create payment link**. +4. Under **After payment**, set **Show confirmation page** to **Redirect** → `https://yourdomain.app/account`. +5. (Optional but recommended) Under **Advanced options → Metadata**, add `tier=pro` (or `team`). Sim2Sim falls back to the amount, but metadata is more robust. +6. Copy both Payment Link URLs. + +## 4. Wire the webhook (3 min) + +1. Stripe Dashboard → **Developers → Webhooks → Add endpoint**. +2. URL: `https://yourdomain.app/api/billing/webhook` +3. Event: select **`checkout.session.completed`**. +4. Click **Add endpoint**, then reveal **Signing secret** (`whsec_...`). + +## 5. Sign up for Resend (2 min) + +1. Create an account at [resend.com](https://resend.com). +2. (Optional for launch) Verify a sending domain — until then, emails go from `onboarding@resend.dev`, which works but looks less polished. +3. Copy your API key (`re_...`). + +## 6. Set environment variables in Replit (5 min) + +In **Replit → Secrets**, add: + +| Variable | Value | +|---|---| +| `ENVIRONMENT` | `production` | +| `ALLOWED_ORIGIN` | `https://yourdomain.app` (the one from step 2) | +| `APP_BASE_URL` | `https://yourdomain.app` | +| `SIM2SIM_LICENSE_SECRET` | run `python -c "import secrets;print(secrets.token_urlsafe(48))"` and paste the output | +| `STRIPE_WEBHOOK_SECRET` | from step 4 | +| `STRIPE_PRICE_PRO_CENTS` | `4900` | +| `STRIPE_PRICE_TEAM_CENTS` | `24900` | +| `RESEND_API_KEY` | from step 5 | +| `RESEND_FROM` | `Sim2Sim ` (or leave default during testing) | +| `ANTHROPIC_API_KEY` | your existing key | + +## 7. Paste your Payment Link URLs (2 min) + +Open `static/pricing.html` and replace the two `__configure_me__` placeholders (search for `STRIPE_PRO_URL` / `STRIPE_TEAM_URL`), or — cleaner — add this script block right above `` in `pricing.html`: + +```html + +``` + +## 8. Deploy + +In Replit, click **Deploy**. The web server is `uvicorn main:app --host 0.0.0.0 --port $PORT`. The SQLite DB persists in the home directory across deploys. + +## 9. Test the full flow (10 min) + +1. Open your live URL → see Pricing nav. +2. Click **Buy Pro** → it opens Stripe Checkout. +3. Use Stripe **test mode** first: card `4242 4242 4242 4242`, any future date, any CVC. +4. Complete checkout → Stripe redirects you to `/account`. +5. Check your email — license key should arrive within seconds. +6. Paste the key into Account → status flips to "Pro". +7. Go to `/` → click any model → click **⬇ Excel** → file downloads. + +If steps 5–7 fail, check **Stripe → Developers → Webhooks → your endpoint → Events** for delivery errors, and **Replit → Logs** for server errors. + +## 10. Flip to live mode + +1. Stripe Dashboard top-right: switch from **Test mode** to **Live mode**. +2. Re-create Payment Links in live mode (test-mode links don't carry over). +3. Re-create the webhook endpoint in live mode → copy the new signing secret → update `STRIPE_WEBHOOK_SECRET` in Replit Secrets. +4. Redeploy. +5. Make a real $1 test purchase (or wait for the first real customer — your call). + +--- + +## Optional polish for v1.1 + +- Verify a sending domain in Resend so emails come from `hello@yourdomain.app`. +- Add OpenGraph image and meta description for nicer social sharing. +- Add a "Save scenario" Pro feature (we already gate it in the pricing copy). +- Branded PDFs for the Team tier (logo upload field on the account page). +- A simple admin page at `/admin` (gated by `ADMIN_PASSWORD`) showing recent purchases. diff --git a/README.md b/README.md index c23c6fc..3caca2f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ -# Sim2Real — Operations Research Platform +# Sim2Sim — Operations Research Platform [![Tests](https://github.com/Gavand1969/sim2sim/actions/workflows/ci.yml/badge.svg)](https://github.com/Gavand1969/sim2sim/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/) -**Live demo:** *(deploy on Replit and paste URL here)* +**Live demo:** *(coming soon — deployment in progress)* +**Pricing & launch guide:** see [LAUNCH.md](LAUNCH.md) PhD-level operations research in your browser — queuing theory, inventory optimization, Monte Carlo simulation, and linear programming with AI-generated insights powered by Claude. -Arena costs $5,000/year. AnyLogic costs $7,000/year. **This is free.** +Arena costs $5,000/year. AnyLogic costs $7,000/year. Sim2Sim is free to use and has an optional one-time **Pro** ($49) or **Team** ($249) upgrade for Excel/PDF export, unlimited AI explanations, advanced inventory models, and commercial-use rights. See [Pricing](static/pricing.html) and [LAUNCH.md](LAUNCH.md) for deployment. + +![Sim2Sim Screenshot](docs/screenshot.png) --- diff --git a/main.py b/main.py index 35d8f62..7793ec3 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ """ -Sim2Real — Operations Research Simulator +Sim2Sim — Operations Research Simulator Entry point: FastAPI app that serves both the REST API and the static frontend. """ import os @@ -15,19 +15,22 @@ from src.api.middleware import limiter, add_security_headers from src.api.routes import router +from src.billing import db as billing_db load_dotenv() @asynccontextmanager async def lifespan(app: FastAPI): - # Startup: nothing to initialise beyond imports + # Startup: initialise the billing DB so license activation works + # immediately on first boot (Replit's filesystem persists across runs). + billing_db.init_db() yield # Shutdown: nothing to clean up app = FastAPI( - title="Sim2Real", + title="Sim2Sim", description="Operations Research Simulator with AI-powered explanations", version="1.0.0", docs_url="/api/docs", @@ -40,10 +43,20 @@ async def lifespan(app: FastAPI): app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # ── CORS ────────────────────────────────────────────────────────────────────── -# In production (Replit) the frontend is served from the same origin, so -# wildcard CORS is only needed in local development. +# In production the frontend is served from the same origin, so wildcard +# CORS is only needed in local development. In production we REQUIRE an +# ALLOWED_ORIGIN so we never silently ship with no CORS policy. _env = os.getenv("ENVIRONMENT", "development") -_origins = ["*"] if _env == "development" else [os.getenv("ALLOWED_ORIGIN", "")] +if _env == "development": + _origins: list[str] = ["*"] +else: + _allowed = os.getenv("ALLOWED_ORIGIN", "").strip() + if not _allowed: + raise RuntimeError( + "ENVIRONMENT=production requires ALLOWED_ORIGIN to be set " + "(comma-separated list of allowed origins)." + ) + _origins = [o.strip() for o in _allowed.split(",") if o.strip()] app.add_middleware( CORSMiddleware, @@ -69,6 +82,17 @@ async def security_headers_middleware(request: Request, call_next): async def serve_frontend(): return FileResponse("static/index.html") + +@app.get("/pricing", include_in_schema=False) +async def serve_pricing(): + return FileResponse("static/pricing.html") + + +@app.get("/account", include_in_schema=False) +async def serve_account(): + return FileResponse("static/account.html") + + # Catch-all so that client-side navigation always returns index.html @app.get("/{full_path:path}", include_in_schema=False) async def catch_all(full_path: str): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..50b0e48 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "sim2sim" +version = "1.0.0" +description = "PhD-level Operations Research platform — queuing, inventory, simulation, LP, with AI insights." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [{ name = "Gavin Anderson" }] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["static/**"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify +] +ignore = [ + "E501", # line too long (we already format thoughtfully) + "E731", # lambda assignment is fine + "B008", # default arg call (FastAPI uses this) +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B011", "F401"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q --strict-markers" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.mypy] +python_version = "3.12" +warn_return_any = false +warn_unused_configs = true +ignore_missing_imports = true +exclude = ["static/", "build/", "dist/"] diff --git a/requirements.txt b/requirements.txt index 93006e7..eadce7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ python-dotenv==1.0.1 httpx==0.28.1 pytest==8.3.4 pytest-asyncio==0.24.0 +openpyxl==3.1.5 +reportlab==4.2.5 diff --git a/src/ai/explainer.py b/src/ai/explainer.py index 78ea7c7..93a0a2c 100644 --- a/src/ai/explainer.py +++ b/src/ai/explainer.py @@ -26,8 +26,24 @@ _MODEL = "claude-haiku-4-5-20251001" _MAX_TOKENS = 700 +# Module-level lazy-initialised client. Created on first use so module import +# never depends on ANTHROPIC_API_KEY being set (tests stay key-free). +_client: anthropic.AsyncAnthropic | None = None + + +def _get_client() -> anthropic.AsyncAnthropic: + global _client + if _client is None: + api_key = os.getenv("ANTHROPIC_API_KEY", "") + if not api_key: + raise EnvironmentError( + "ANTHROPIC_API_KEY is not configured. Add it to your .env file." + ) + _client = anthropic.AsyncAnthropic(api_key=api_key) + return _client + _SYSTEM_PROMPT = """\ -You are a PhD-level operations research expert embedded in Sim2Real, \ +You are a PhD-level operations research expert embedded in Sim2Sim, \ a professional OR platform. You explain quantitative results to engineers, \ analysts, and students. @@ -89,13 +105,7 @@ async def explain( EnvironmentError if ANTHROPIC_API_KEY is not set. anthropic.APIError on API-level failures (caller handles). """ - api_key = os.getenv("ANTHROPIC_API_KEY", "") - if not api_key: - raise EnvironmentError( - "ANTHROPIC_API_KEY is not configured. Add it to your .env file." - ) - - client = anthropic.AsyncAnthropic(api_key=api_key) + client = _get_client() user_message = _build_user_message(model_type, parameters, results) message = await client.messages.create( diff --git a/src/api/routes.py b/src/api/routes.py index ba559b2..c8187b4 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -16,7 +16,7 @@ import logging import anthropic -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response from src.ai.explainer import explain from src.api.schemas import ( @@ -26,6 +26,7 @@ EOQRequest, EOQResponse, EPQRequest, EPQResponse, ExplainRequest, ExplainResponse, + ExportInventoryRequest, ExportLPRequest, ExportQueuingRequest, LPRequest, LPResponse, NewsvendorRequest, NewsvendorResponse, QueuingRequest, QueuingResponse, @@ -33,6 +34,14 @@ ScenarioCompareRequest, ScenarioCompareResponse, SimulationRequest, SimulationResponse, ) +from src.billing import licenses as billing_licenses +from src.billing.licenses import require_pro +from src.billing.schemas import ( + ActivateRequest, ActivateResponse, LicenseStatusResponse, WebhookAck, +) +from src.billing.webhook import handle_stripe_webhook +from src.export.excel import build_inventory_xlsx, build_lp_xlsx, build_queuing_xlsx +from src.export.pdf import build_inventory_pdf, build_lp_pdf, build_queuing_pdf from src.models.inventory import ( solve_base_stock, solve_eoq, solve_eoq_backorder, solve_epq, solve_newsvendor, solve_reorder_point, @@ -217,7 +226,8 @@ async def newsvendor_endpoint(body: NewsvendorRequest): async def reorder_point_endpoint(body: ReorderPointRequest): try: r = solve_reorder_point( - D=body.annual_demand, L=body.lead_time, sigma_d=body.demand_std_day, + D=body.annual_demand, L_days=body.lead_time_days, + sigma_d=body.demand_std_day, K=body.ordering_cost, c=body.unit_cost, i=body.holding_rate, service_level=body.service_level, ) @@ -239,7 +249,8 @@ async def reorder_point_endpoint(body: ReorderPointRequest): async def base_stock_endpoint(body: BaseStockRequest): try: r = solve_base_stock( - D=body.annual_demand, L=body.lead_time, sigma_d=body.demand_std_day, + D=body.annual_demand, L_days=body.lead_time_days, + sigma_d=body.demand_std_day, c=body.unit_cost, i=body.holding_rate, service_level=body.service_level, ) except Exception: @@ -353,6 +364,130 @@ async def cpm_endpoint(body: CPMRequest): ) +# ── Pro: Exports ─────────────────────────────────────────────────────────────── + +@router.post("/export/queuing/xlsx", tags=["exports"]) +async def export_queuing_xlsx( + body: ExportQueuingRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export queuing results as an Excel workbook (Pro).""" + content = build_queuing_xlsx(result=body.result, params=body.params) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="sim2sim-queuing.xlsx"'}, + ) + + +@router.post("/export/queuing/pdf", tags=["exports"]) +async def export_queuing_pdf( + body: ExportQueuingRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export queuing results as a PDF report (Pro).""" + content = build_queuing_pdf(result=body.result, params=body.params) + return Response( + content=content, + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="sim2sim-queuing.pdf"'}, + ) + + +@router.post("/export/inventory/xlsx", tags=["exports"]) +async def export_inventory_xlsx( + body: ExportInventoryRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export inventory results as an Excel workbook (Pro).""" + content = build_inventory_xlsx(result=body.result, params=body.params, model_kind=body.model_kind) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="sim2sim-inventory.xlsx"'}, + ) + + +@router.post("/export/inventory/pdf", tags=["exports"]) +async def export_inventory_pdf( + body: ExportInventoryRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export inventory results as a PDF report (Pro).""" + content = build_inventory_pdf(result=body.result, params=body.params, model_kind=body.model_kind) + return Response( + content=content, + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="sim2sim-inventory.pdf"'}, + ) + + +@router.post("/export/lp/xlsx", tags=["exports"]) +async def export_lp_xlsx( + body: ExportLPRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export LP optimization results as an Excel workbook (Pro).""" + content = build_lp_xlsx(result=body.result, params=body.params) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="sim2sim-lp.xlsx"'}, + ) + + +@router.post("/export/lp/pdf", tags=["exports"]) +async def export_lp_pdf( + body: ExportLPRequest, + _license: str = Depends(require_pro), +) -> Response: + """Export LP optimization results as a PDF report (Pro).""" + content = build_lp_pdf(result=body.result, params=body.params) + return Response( + content=content, + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="sim2sim-lp.pdf"'}, + ) + + +# ── Billing: license activation + Stripe webhook ────────────────────────────── + +@router.post("/billing/activate", response_model=ActivateResponse, tags=["billing"]) +async def billing_activate(body: ActivateRequest): + """ + Activate a license key. Returns tier/email and bumps the activation + counter on the server. Idempotent — a key can be activated repeatedly. + """ + info = billing_licenses.activate_license(body.key) + if info is None: + return ActivateResponse(valid=False, message="Invalid or unknown license key.") + return ActivateResponse( + valid=True, tier=info.tier, email=info.email, + activated_at=info.activated_at, + ) + + +@router.get("/billing/status", response_model=LicenseStatusResponse, tags=["billing"]) +async def billing_status(x_license_key: str = Header(default="")): + """ + Check current license status without recording an activation. The + frontend calls this on page load to decide whether to show Pro UI. + """ + info = billing_licenses.validate_license(x_license_key) + if info is None: + return LicenseStatusResponse(valid=False, message="No valid license key.") + return LicenseStatusResponse( + valid=True, tier=info.tier, email=info.email, + activation_count=info.activation_count, + ) + + +@router.post("/billing/webhook", response_model=WebhookAck, tags=["billing"]) +async def billing_webhook(request: Request): + """Stripe webhook receiver — see src/billing/webhook.py for details.""" + return await handle_stripe_webhook(request) + + # ── AI Explanation ──────────────────────────────────────────────────────────── @router.post("/explain", response_model=ExplainResponse, tags=["ai"]) diff --git a/src/api/schemas.py b/src/api/schemas.py index 385fb45..4174168 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -195,8 +195,8 @@ class NewsvendorResponse(BaseModel): class ReorderPointRequest(BaseModel): annual_demand: float = Field(..., gt=0, le=10_000_000, description="D: annual demand (units/year)") - lead_time: float = Field(..., gt=0, le=5, description="L: replenishment lead time (years)") - demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of daily demand") + lead_time_days: float = Field(..., gt=0, le=730, description="L: replenishment lead time, in DAYS (max 730 = 2 years)") + demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of DAILY demand (must match lead-time unit)") ordering_cost: float = Field(..., gt=0, le=1_000_000) unit_cost: float = Field(..., gt=0, le=100_000) holding_rate: float = Field(..., gt=0, le=1) @@ -219,9 +219,9 @@ class ReorderPointResponse(BaseModel): # ── Inventory: Base Stock ───────────────────────────────────────────────────── class BaseStockRequest(BaseModel): - annual_demand: float = Field(..., gt=0, le=10_000_000) - lead_time: float = Field(..., gt=0, le=5) - demand_std_day: float = Field(..., ge=0, le=100_000) + annual_demand: float = Field(..., gt=0, le=10_000_000, description="D: annual demand (units/year)") + lead_time_days: float = Field(..., gt=0, le=730, description="L: replenishment lead time, in DAYS") + demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of DAILY demand") unit_cost: float = Field(..., gt=0, le=100_000) holding_rate: float = Field(..., gt=0, le=1) service_level: float = Field(0.95, gt=0, lt=1) @@ -338,6 +338,28 @@ class CPMResponse(BaseModel): tasks: dict[str, Any] +# ── Pro: Export request schemas ────────────────────────────────────────────── + +class ExportQueuingRequest(BaseModel): + params: dict = Field(..., description="Queuing request parameters") + result: dict = Field(..., description="Queuing result payload") + + +class ExportInventoryRequest(BaseModel): + params: dict = Field(..., description="Inventory request parameters") + result: dict = Field(..., description="Inventory result payload") + model_kind: str = Field( + ..., + pattern="^(eoq|eoq_backorder|epq|newsvendor|reorder_point|base_stock)$", + description="Inventory model variant", + ) + + +class ExportLPRequest(BaseModel): + params: dict = Field(..., description="LP request parameters") + result: dict = Field(..., description="LP result payload") + + # ── AI Explanation ──────────────────────────────────────────────────────────── class ExplainRequest(BaseModel): diff --git a/src/billing/__init__.py b/src/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/billing/db.py b/src/billing/db.py new file mode 100644 index 0000000..49c7af4 --- /dev/null +++ b/src/billing/db.py @@ -0,0 +1,131 @@ +""" +SQLite storage for license keys. + +Schema: + licenses( + key TEXT PRIMARY KEY, -- LIC-XXXX-XXXX-XXXX-XXXX + tier TEXT NOT NULL, -- 'pro' | 'team' + email TEXT NOT NULL, + created_at TEXT NOT NULL, -- ISO-8601 UTC + stripe_session_id TEXT, -- nullable, used for idempotency + activated_at TEXT, -- nullable; first activation timestamp + activation_count INTEGER NOT NULL DEFAULT 0, + revoked INTEGER NOT NULL DEFAULT 0 + ) + +Notes: + - We don't bind to a machine ID — license keys are paste-anywhere, and we + just bump activation_count for analytics. + - The database file path is taken from the SIM2SIM_DB env var, defaulting + to ./sim2sim.db. Tests override via the env var. +""" +from __future__ import annotations + +import os +import sqlite3 +import threading +from pathlib import Path +from typing import Optional + +_LOCK = threading.Lock() +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS licenses ( + key TEXT PRIMARY KEY, + tier TEXT NOT NULL CHECK (tier IN ('pro', 'team')), + email TEXT NOT NULL, + created_at TEXT NOT NULL, + stripe_session_id TEXT UNIQUE, + activated_at TEXT, + activation_count INTEGER NOT NULL DEFAULT 0, + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_licenses_email ON licenses (email); +CREATE INDEX IF NOT EXISTS idx_licenses_stripe ON licenses (stripe_session_id); +""" + + +def db_path() -> Path: + return Path(os.getenv("SIM2SIM_DB", "sim2sim.db")) + + +def get_conn() -> sqlite3.Connection: + """Return a new SQLite connection with sensible pragmas.""" + path = db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA foreign_keys=ON;") + return conn + + +def init_db() -> None: + """Create tables if they don't exist. Safe to call repeatedly.""" + with _LOCK: + conn = get_conn() + try: + conn.executescript(_SCHEMA) + finally: + conn.close() + + +def reset_db() -> None: + """Drop and recreate. Test-only helper.""" + with _LOCK: + path = db_path() + if path.exists(): + path.unlink() + init_db() + + +def find_by_stripe_session(session_id: str) -> Optional[sqlite3.Row]: + conn = get_conn() + try: + cur = conn.execute( + "SELECT * FROM licenses WHERE stripe_session_id = ?", + (session_id,), + ) + return cur.fetchone() + finally: + conn.close() + + +def find_by_key(key: str) -> Optional[sqlite3.Row]: + conn = get_conn() + try: + cur = conn.execute("SELECT * FROM licenses WHERE key = ?", (key,)) + return cur.fetchone() + finally: + conn.close() + + +def insert_license( + key: str, + tier: str, + email: str, + created_at: str, + stripe_session_id: Optional[str] = None, +) -> None: + conn = get_conn() + try: + conn.execute( + "INSERT INTO licenses (key, tier, email, created_at, stripe_session_id) " + "VALUES (?, ?, ?, ?, ?)", + (key, tier, email, created_at, stripe_session_id), + ) + finally: + conn.close() + + +def record_activation(key: str, activated_at: str) -> None: + conn = get_conn() + try: + conn.execute( + "UPDATE licenses " + "SET activation_count = activation_count + 1, " + " activated_at = COALESCE(activated_at, ?) " + "WHERE key = ?", + (activated_at, key), + ) + finally: + conn.close() diff --git a/src/billing/email.py b/src/billing/email.py new file mode 100644 index 0000000..d38fccc --- /dev/null +++ b/src/billing/email.py @@ -0,0 +1,96 @@ +""" +Transactional email via Resend (https://resend.com). + +Resend was chosen because: + - Single HTTPS POST, no SDK required (we use httpx). + - Free tier of 100 emails/day is plenty for a paid-MVP launch. + - Their API stays stable, so a generic httpx client is low-risk. + +If RESEND_API_KEY is unset (typical in dev), `send_license_email` returns +a synthetic result with `delivered=False` and a reason — we do NOT raise, +because we never want a failed email to block a paid customer's webhook. +""" +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +_RESEND_URL = "https://api.resend.com/emails" + + +@dataclass +class EmailResult: + delivered: bool + provider_id: Optional[str] = None + error: Optional[str] = None + + +def _from_address() -> str: + return os.getenv("RESEND_FROM", "Sim2Sim ") + + +def _build_license_html(license_key: str, tier: str, app_url: str) -> str: + tier_label = "Team" if tier == "team" else "Pro" + return f""" + +

Thanks for buying Sim2Sim {tier_label}.

+

+ Your lifetime license key is below. Paste it into the + Account page + on Sim2Sim and your account unlocks immediately. +

+
+ {license_key} +
+

+ Keep this email — your key never expires. If you switch browsers or devices, + paste the same key on the account page to unlock {tier_label} again. +

+

+ Reply to this email if anything looks off. +

+""" + + +async def send_license_email( + to_email: str, + license_key: str, + tier: str, + app_url: Optional[str] = None, +) -> EmailResult: + """Send a license-delivery email. Never raises — returns EmailResult.""" + api_key = os.getenv("RESEND_API_KEY", "") + if not api_key: + logger.warning("RESEND_API_KEY not set — skipping license email to %s", to_email) + return EmailResult(delivered=False, error="RESEND_API_KEY not configured") + + app_url = app_url or os.getenv("APP_BASE_URL", "https://sim2sim.app") + payload = { + "from": _from_address(), + "to": [to_email], + "subject": f"Your Sim2Sim {('Team' if tier == 'team' else 'Pro')} license key", + "html": _build_license_html(license_key, tier, app_url), + } + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + _RESEND_URL, + json=payload, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if resp.status_code >= 400: + logger.error("Resend %s: %s", resp.status_code, resp.text[:200]) + return EmailResult(delivered=False, error=f"HTTP {resp.status_code}") + data = resp.json() + return EmailResult(delivered=True, provider_id=data.get("id")) + except Exception as exc: # noqa: BLE001 + logger.exception("Resend send failed") + return EmailResult(delivered=False, error=str(exc)) diff --git a/src/billing/licenses.py b/src/billing/licenses.py new file mode 100644 index 0000000..94f8afa --- /dev/null +++ b/src/billing/licenses.py @@ -0,0 +1,247 @@ +""" +License key generation, validation, and the FastAPI `require_pro` dependency. + +Key format +---------- + LIC-XXXX-XXXX-XXXX-XXXX +where each X is from Crockford's base32 alphabet (no I, L, O, U). + +Generation is HMAC-based: + payload = ":" + sig = HMAC-SHA256(SIM2SIM_LICENSE_SECRET, payload)[:10 bytes] +We then base32-encode `random_id || sig` and format with dashes. This means +keys are self-signed: even before hitting the DB we can detect bogus keys +client-side typos and stop wasting DB queries. The DB is still the source +of truth for tier, email, and revocation. + +Dev convenience +--------------- +In ENVIRONMENT=development, the bypass key "dev" and an empty header both +unlock Pro endpoints, so the demo still works without setting up Stripe. +""" +from __future__ import annotations + +import base64 +import datetime as dt +import hmac +import hashlib +import os +import secrets +from dataclasses import dataclass +from typing import Optional + +from fastapi import Header, HTTPException + +from src.billing import db + +# Crockford-ish base32 alphabet (no I, L, O, U to avoid confusion in printed keys). +_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +_TIER_FREE = "free" +_TIER_PRO = "pro" +_TIER_TEAM = "team" +_VALID_TIERS = {_TIER_PRO, _TIER_TEAM} + + +def _secret() -> bytes: + """The HMAC secret used to sign license keys.""" + s = os.getenv("SIM2SIM_LICENSE_SECRET", "") + if not s: + # Development fallback so the demo runs out of the box. Production + # deployments MUST set this in the environment. + env = os.getenv("ENVIRONMENT", "development") + if env != "development": + raise RuntimeError( + "SIM2SIM_LICENSE_SECRET must be set in non-development environments." + ) + s = "sim2sim-dev-license-secret-do-not-use-in-prod" + return s.encode("utf-8") + + +def _b32_encode(data: bytes) -> str: + """Encode bytes using our custom base32 alphabet (no padding).""" + # Convert bytes to integer, then to base32 digits. + n = int.from_bytes(data, "big") + # Each byte ~ 1.6 base32 digits; we'll pad to a known length so decoding is unambiguous. + digits = [] + if n == 0: + digits.append(_ALPHABET[0]) + while n > 0: + digits.append(_ALPHABET[n % 32]) + n //= 32 + digits.reverse() + return "".join(digits) + + +def _format_key(raw: str) -> str: + """Take a 16-char raw string and format as LIC-XXXX-XXXX-XXXX-XXXX.""" + assert len(raw) == 16, f"raw key must be 16 chars, got {len(raw)}" + return f"LIC-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}-{raw[12:16]}" + + +def _strip_key(key: str) -> str: + """Normalise: remove dashes, uppercase, strip whitespace.""" + return key.strip().upper().replace("-", "").replace("LIC", "", 1) + + +def _hmac_sig(payload: str) -> bytes: + return hmac.new(_secret(), payload.encode("utf-8"), hashlib.sha256).digest() + + +def generate_key(tier: str) -> str: + """ + Generate a new license key for the given tier. + The key embeds a 5-byte random id and a 5-byte HMAC signature, encoded + into 16 base32 characters total (10 bytes ≈ 16 base32 digits). + """ + if tier not in _VALID_TIERS: + raise ValueError(f"invalid tier: {tier!r}") + + random_id = secrets.token_bytes(5) # 40 bits of entropy + payload = f"{tier}:{random_id.hex()}" + sig = _hmac_sig(payload)[:5] # 40-bit truncated HMAC + + # 10 bytes → exactly 16 base32 digits (80 bits / 5). + raw_bytes = random_id + sig + encoded = _b32_encode(raw_bytes) + # Pad with leading zeros up to 16 chars (encoding of small ints is short). + encoded = encoded.rjust(16, _ALPHABET[0]) + return _format_key(encoded) + + +def _key_is_well_formed(key: str) -> bool: + """Cheap syntactic check — does NOT verify signature.""" + if not key.startswith("LIC-"): + return False + parts = key.split("-") + if len(parts) != 5: + return False + if any(len(p) != 4 for p in parts[1:]): + return False + body = "".join(parts[1:]) + return all(c in _ALPHABET for c in body) + + +# ── Domain dataclass ────────────────────────────────────────────────────────── + + +@dataclass +class LicenseInfo: + key: str + tier: str # 'pro' | 'team' + email: str + created_at: str + activated_at: Optional[str] + activation_count: int + + +def _row_to_info(row) -> LicenseInfo: + return LicenseInfo( + key=row["key"], + tier=row["tier"], + email=row["email"], + created_at=row["created_at"], + activated_at=row["activated_at"], + activation_count=row["activation_count"], + ) + + +# ── Public API ──────────────────────────────────────────────────────────────── + + +def create_license( + tier: str, + email: str, + stripe_session_id: Optional[str] = None, +) -> LicenseInfo: + """ + Generate a new key and persist it. Idempotent on stripe_session_id — + if a license already exists for the session, returns the existing one. + """ + if tier not in _VALID_TIERS: + raise ValueError(f"invalid tier: {tier!r}") + + # Idempotency check + if stripe_session_id: + existing = db.find_by_stripe_session(stripe_session_id) + if existing is not None: + return _row_to_info(existing) + + key = generate_key(tier) + created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + db.insert_license( + key=key, tier=tier, email=email, created_at=created_at, + stripe_session_id=stripe_session_id, + ) + row = db.find_by_key(key) + assert row is not None + return _row_to_info(row) + + +def validate_license(key: str) -> Optional[LicenseInfo]: + """ + Validate a license key. Returns LicenseInfo on success, None otherwise. + Performs cheap syntactic check before DB lookup. + """ + if not key: + return None + if not _key_is_well_formed(key): + return None + row = db.find_by_key(key.strip().upper()) + if row is None: + return None + if row["revoked"]: + return None + return _row_to_info(row) + + +def activate_license(key: str) -> Optional[LicenseInfo]: + """ + Validate AND record an activation (bump counter, set activated_at). + Returns LicenseInfo on success, None otherwise. + """ + info = validate_license(key) + if info is None: + return None + now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + db.record_activation(info.key, now) + return validate_license(info.key) + + +# ── FastAPI dependency ──────────────────────────────────────────────────────── + + +def _dev_bypass(key: str) -> bool: + env = os.getenv("ENVIRONMENT", "development") + return env == "development" and key in ("", "dev", "DEV") + + +async def require_pro(x_license_key: str = Header(default="")) -> str: + """ + FastAPI dependency: gates Pro endpoints. In development the bypass key + "dev" (or an empty header) is accepted so the demo still runs without + Stripe. In production, the key must validate against the database. + """ + if _dev_bypass(x_license_key): + return "dev" + info = validate_license(x_license_key) + if info is None: + raise HTTPException( + status_code=403, + detail="Valid Pro or Team license key required.", + ) + if info.tier not in _VALID_TIERS: + raise HTTPException(status_code=403, detail="License tier does not include this feature.") + return info.key + + +async def require_team(x_license_key: str = Header(default="")) -> str: + """Stricter dependency: only Team-tier license keys pass.""" + if _dev_bypass(x_license_key): + return "dev" + info = validate_license(x_license_key) + if info is None or info.tier != _TIER_TEAM: + raise HTTPException( + status_code=403, + detail="Team license key required.", + ) + return info.key diff --git a/src/billing/schemas.py b/src/billing/schemas.py new file mode 100644 index 0000000..c84da95 --- /dev/null +++ b/src/billing/schemas.py @@ -0,0 +1,34 @@ +"""Pydantic schemas for the billing endpoints.""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class ActivateRequest(BaseModel): + """POST /api/billing/activate body.""" + key: str = Field(..., min_length=1, max_length=64, description="License key to activate") + + +class ActivateResponse(BaseModel): + valid: bool + tier: Optional[str] = None + email: Optional[str] = None + activated_at: Optional[str] = None + message: Optional[str] = None + + +class LicenseStatusResponse(BaseModel): + """GET /api/billing/status response.""" + valid: bool + tier: Optional[str] = None + email: Optional[str] = None + activation_count: Optional[int] = None + message: Optional[str] = None + + +class WebhookAck(BaseModel): + received: bool + action: str + license_key: Optional[str] = None diff --git a/src/billing/webhook.py b/src/billing/webhook.py new file mode 100644 index 0000000..60a2040 --- /dev/null +++ b/src/billing/webhook.py @@ -0,0 +1,188 @@ +""" +Stripe webhook handler. + +We support a single event: `checkout.session.completed`. When it fires: + 1. Identify the tier from the line items or session metadata. + 2. Generate a new license (idempotent on stripe session_id). + 3. Email the key to the customer via Resend. + +We deliberately keep this small: + - We don't depend on the `stripe` Python SDK — we parse the JSON envelope + ourselves and verify the signature with stdlib `hmac`. This avoids + dragging in 50+ MB of unused Stripe code. + - We accept either tier metadata (preferred) or a configurable + STRIPE_PRICE_PRO / STRIPE_PRICE_TEAM mapping, so the user can connect + Stripe Payment Links without writing any custom session-creation code. +""" +from __future__ import annotations + +import hmac +import hashlib +import json +import logging +import os +import time +from typing import Optional + +from fastapi import HTTPException, Request + +from src.billing import licenses +from src.billing.email import send_license_email +from src.billing.schemas import WebhookAck + +logger = logging.getLogger(__name__) + +# 5-minute tolerance for Stripe timestamps (matches Stripe's own docs). +_SIG_TOLERANCE_SECONDS = 300 + + +# ── Signature verification ──────────────────────────────────────────────────── + + +def _parse_sig_header(header: str) -> tuple[Optional[int], list[str]]: + """ + Parse Stripe's `Stripe-Signature` header: + t=12345,v1=abc...,v1=def... + Returns (timestamp, [v1 signatures]). + """ + if not header: + return None, [] + ts: Optional[int] = None + sigs: list[str] = [] + for item in header.split(","): + if "=" not in item: + continue + k, _, v = item.partition("=") + k, v = k.strip(), v.strip() + if k == "t": + try: + ts = int(v) + except ValueError: + ts = None + elif k == "v1": + sigs.append(v) + return ts, sigs + + +def verify_signature( + payload: bytes, + sig_header: str, + secret: str, + *, + now: Optional[int] = None, +) -> bool: + """Verify the Stripe webhook signature using stdlib hmac.""" + if not secret: + return False + ts, sigs = _parse_sig_header(sig_header) + if ts is None or not sigs: + return False + if now is None: + now = int(time.time()) + if abs(now - ts) > _SIG_TOLERANCE_SECONDS: + return False + signed_payload = f"{ts}.".encode("utf-8") + payload + expected = hmac.new( + secret.encode("utf-8"), signed_payload, hashlib.sha256 + ).hexdigest() + return any(hmac.compare_digest(expected, sig) for sig in sigs) + + +# ── Tier resolution ─────────────────────────────────────────────────────────── + + +def _resolve_tier(session: dict) -> Optional[str]: + """ + Resolve the tier from a Stripe session payload. Order of precedence: + 1. session.metadata.tier == "pro" | "team" + 2. session.amount_total compared against STRIPE_PRICE_*_CENTS + 3. STRIPE_PRICE_PRO / STRIPE_PRICE_TEAM matched against line_items + """ + metadata = session.get("metadata") or {} + tier_meta = (metadata.get("tier") or "").lower().strip() + if tier_meta in ("pro", "team"): + return tier_meta + + # Amount fallback — robust because user can paste their Payment Link + # without setting metadata at all. + pro_cents = _env_int("STRIPE_PRICE_PRO_CENTS", 4900) + team_cents = _env_int("STRIPE_PRICE_TEAM_CENTS", 24900) + amount = session.get("amount_total") + if isinstance(amount, int): + if amount == pro_cents: + return "pro" + if amount == team_cents: + return "team" + return None + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +def _extract_email(session: dict) -> Optional[str]: + cd = session.get("customer_details") or {} + return ( + session.get("customer_email") + or cd.get("email") + or None + ) + + +# ── Main handler ────────────────────────────────────────────────────────────── + + +async def handle_stripe_webhook(request: Request) -> WebhookAck: + """ + Entry point — wired into a FastAPI route in routes.py. + + Returns 200 OK with a small JSON acknowledgement so Stripe doesn't retry. + Raises HTTPException(400) only on signature failure. + """ + payload = await request.body() + sig_header = request.headers.get("stripe-signature", "") + secret = os.getenv("STRIPE_WEBHOOK_SECRET", "") + + # In development the secret may be unset and we skip verification. We + # log loudly so this can't slip into production by accident. + env = os.getenv("ENVIRONMENT", "development") + if env == "production": + if not verify_signature(payload, sig_header, secret): + raise HTTPException(status_code=400, detail="Invalid Stripe signature.") + else: + if not secret: + logger.warning("STRIPE_WEBHOOK_SECRET unset — accepting webhook unverified (dev only).") + elif not verify_signature(payload, sig_header, secret): + logger.warning("Stripe signature check failed (dev) — accepting anyway.") + + try: + event = json.loads(payload) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Webhook payload was not valid JSON.") + + event_type = event.get("type", "") + data_object = (event.get("data") or {}).get("object") or {} + + if event_type != "checkout.session.completed": + # Acknowledge any other event without doing anything. + return WebhookAck(received=True, action=f"ignored:{event_type}") + + session_id = data_object.get("id") or "" + email = _extract_email(data_object) + tier = _resolve_tier(data_object) + + if not email or not tier: + logger.warning( + "Webhook missing email/tier — id=%s email=%s tier=%s", + session_id, email, tier, + ) + return WebhookAck(received=True, action="skipped:missing_email_or_tier") + + info = licenses.create_license(tier=tier, email=email, stripe_session_id=session_id) + await send_license_email( + to_email=email, license_key=info.key, tier=info.tier, + ) + return WebhookAck(received=True, action="license_created", license_key=info.key) diff --git a/src/export/__init__.py b/src/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/export/excel.py b/src/export/excel.py new file mode 100644 index 0000000..c4caf74 --- /dev/null +++ b/src/export/excel.py @@ -0,0 +1,257 @@ +""" +Excel workbook builders for Pro-tier export feature. +Uses openpyxl to produce .xlsx files in-memory. +""" +from __future__ import annotations + +from io import BytesIO +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment +from openpyxl.utils import get_column_letter + + +# ── Shared helpers ───────────────────────────────────────────────────────────── + +_BOLD = Font(bold=True) +_HEADER_FILL = PatternFill("solid", fgColor="1F3864") # navy +_HEADER_FONT = Font(bold=True, color="FFFFFF") + + +def _write_header_row(ws, headers: list[str]) -> None: + """Write a bold header row in row 1 with navy background.""" + for col_idx, header in enumerate(headers, start=1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = _HEADER_FONT + cell.fill = _HEADER_FILL + cell.alignment = Alignment(horizontal="left") + + +def _freeze_and_autosize(ws, first_col_width: int = 28) -> None: + """Freeze row 1 and set a reasonable width for column A.""" + ws.freeze_panes = "A2" + ws.column_dimensions["A"].width = first_col_width + + +def _write_kv_section(ws, data: dict[str, Any], start_row: int = 2) -> int: + """Write label/value pairs starting at start_row; return next empty row.""" + row = start_row + for key, value in data.items(): + if value is None: + continue + ws.cell(row=row, column=1, value=key) + ws.cell(row=row, column=2, value=value) + row += 1 + return row + + +def _to_bytes(wb: Workbook) -> bytes: + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +# ── Queuing workbook ─────────────────────────────────────────────────────────── + +def build_queuing_xlsx(result: dict, params: dict) -> bytes: + """Build a 3-sheet workbook for queuing analysis results.""" + wb = Workbook() + + # ── Sheet 1: Summary ────────────────────────────────────────────────────── + ws_sum = wb.active + ws_sum.title = "Summary" + _write_header_row(ws_sum, ["Metric", "Value"]) + _freeze_and_autosize(ws_sum) + + summary_keys = [ + ("model", "Model"), + ("utilization", "Utilization (ρ)"), + ("L", "Avg Customers in System (L)"), + ("Lq", "Avg Customers in Queue (Lq)"), + ("W", "Avg Time in System (W)"), + ("Wq", "Avg Time in Queue (Wq)"), + ("P0", "Probability System Empty (P0)"), + ("P_wait", "Probability of Wait (P_wait)"), + ("little_law_check", "Little's Law Check"), + ("blocking_prob", "Blocking Probability"), + ("effective_lam", "Effective Arrival Rate"), + ("notes", "Notes"), + ] + row = 2 + for key, label in summary_keys: + val = result.get(key) + if val is None: + continue + ws_sum.cell(row=row, column=1, value=label) + ws_sum.cell(row=row, column=2, value=val) + row += 1 + + # ── Sheet 2: Probability Distribution ──────────────────────────────────── + ws_prob = wb.create_sheet("Probability Distribution") + _write_header_row(ws_prob, ["n", "P(N=n)"]) + _freeze_and_autosize(ws_prob, first_col_width=8) + + prob_dist = result.get("prob_dist", []) + for n, p in enumerate(prob_dist[:21]): + ws_prob.cell(row=n + 2, column=1, value=n) + ws_prob.cell(row=n + 2, column=2, value=p) + + # ── Sheet 3: Inputs ─────────────────────────────────────────────────────── + ws_inp = wb.create_sheet("Inputs") + _write_header_row(ws_inp, ["Parameter", "Value"]) + _freeze_and_autosize(ws_inp) + _write_kv_section(ws_inp, params) + + return _to_bytes(wb) + + +# ── Inventory workbook ───────────────────────────────────────────────────────── + +def build_inventory_xlsx(result: dict, params: dict, model_kind: str) -> bytes: + """Build a 3-sheet workbook for inventory model results.""" + wb = Workbook() + + # ── Sheet 1: Summary ────────────────────────────────────────────────────── + ws_sum = wb.active + ws_sum.title = "Summary" + _write_header_row(ws_sum, ["Metric", "Value"]) + _freeze_and_autosize(ws_sum) + + row = 2 + # Add model kind as first row + ws_sum.cell(row=row, column=1, value="Model") + ws_sum.cell(row=row, column=2, value=model_kind) + row += 1 + for key, value in result.items(): + if value is None: + continue + if isinstance(value, list): + continue # skip list/curve fields + ws_sum.cell(row=row, column=1, value=key) + ws_sum.cell(row=row, column=2, value=value) + row += 1 + + # ── Sheet 2: Cost Curve (if available) ─────────────────────────────────── + has_cost_curve = "cost_curve_q" in result or "profit_curve_q" in result + if has_cost_curve: + ws_curve = wb.create_sheet("Cost Curve") + + # Determine column layout based on what's available + qs = result.get("cost_curve_q", result.get("profit_curve_q", [])) + tcs = result.get("cost_curve_tc", result.get("profit_curve_ep", [])) + holdings = result.get("cost_curve_holding", []) + orderings = result.get("cost_curve_ordering", []) + + headers = ["Q"] + if tcs: + headers.append("TC" if "cost_curve_tc" in result else "Expected Profit") + if holdings: + headers.append("Holding") + if orderings: + headers.append("Ordering") + + _write_header_row(ws_curve, headers) + _freeze_and_autosize(ws_curve, first_col_width=12) + + for i, q in enumerate(qs): + ws_curve.cell(row=i + 2, column=1, value=q) + col = 2 + if tcs and i < len(tcs): + ws_curve.cell(row=i + 2, column=col, value=tcs[i]) + col += 1 + if holdings and i < len(holdings): + ws_curve.cell(row=i + 2, column=col, value=holdings[i]) + col += 1 + if orderings and i < len(orderings): + ws_curve.cell(row=i + 2, column=col, value=orderings[i]) + + # ── Sheet 3: Inputs ─────────────────────────────────────────────────────── + ws_inp = wb.create_sheet("Inputs") + _write_header_row(ws_inp, ["Parameter", "Value"]) + _freeze_and_autosize(ws_inp) + _write_kv_section(ws_inp, params) + + return _to_bytes(wb) + + +# ── LP workbook ──────────────────────────────────────────────────────────────── + +def build_lp_xlsx(result: dict, params: dict) -> bytes: + """Build a 3-sheet workbook for LP optimization results.""" + wb = Workbook() + + # ── Sheet 1: Solution ───────────────────────────────────────────────────── + ws_sol = wb.active + ws_sol.title = "Solution" + _write_header_row(ws_sol, ["Item", "Value"]) + _freeze_and_autosize(ws_sol) + + row = 2 + ws_sol.cell(row=row, column=1, value="Status") + ws_sol.cell(row=row, column=2, value=result.get("status", "")) + row += 1 + ws_sol.cell(row=row, column=1, value="Optimal Value") + ws_sol.cell(row=row, column=2, value=result.get("optimal_value")) + row += 2 # blank separator + + # Variable rows with header + ws_sol.cell(row=row, column=1, value="Variable") + ws_sol.cell(row=row, column=2, value="Value") + ws_sol.cell(row=row, column=3, value="Reduced Cost") + for col_idx in range(1, 4): + cell = ws_sol.cell(row=row, column=col_idx) + cell.font = _BOLD + row += 1 + + variables = result.get("variables", {}) + reduced_costs = result.get("reduced_costs", {}) + for name, value in variables.items(): + ws_sol.cell(row=row, column=1, value=name) + ws_sol.cell(row=row, column=2, value=value) + ws_sol.cell(row=row, column=3, value=reduced_costs.get(name)) + row += 1 + + # ── Sheet 2: Constraints ────────────────────────────────────────────────── + ws_con = wb.create_sheet("Constraints") + _write_header_row(ws_con, ["Name", "Shadow Price", "Slack", "Binding", "RHS Lower", "RHS Current", "RHS Upper"]) + _freeze_and_autosize(ws_con) + ws_con.column_dimensions["B"].width = 14 + ws_con.column_dimensions["C"].width = 12 + ws_con.column_dimensions["D"].width = 10 + ws_con.column_dimensions["E"].width = 12 + ws_con.column_dimensions["F"].width = 14 + ws_con.column_dimensions["G"].width = 12 + + shadow_prices = result.get("shadow_prices", {}) + slacks = result.get("slacks", {}) + binding_constraints = result.get("binding_constraints", []) + rhs_ranges = result.get("rhs_ranges", {}) + + row = 2 + for name in shadow_prices: + rhs_range = rhs_ranges.get(name, {}) + ws_con.cell(row=row, column=1, value=name) + ws_con.cell(row=row, column=2, value=shadow_prices.get(name)) + ws_con.cell(row=row, column=3, value=slacks.get(name)) + ws_con.cell(row=row, column=4, value="Y" if name in binding_constraints else "N") + ws_con.cell(row=row, column=5, value=rhs_range.get("lower")) + ws_con.cell(row=row, column=6, value=rhs_range.get("current")) + ws_con.cell(row=row, column=7, value=rhs_range.get("upper")) + row += 1 + + # ── Sheet 3: Objective Ranging ──────────────────────────────────────────── + ws_obj = wb.create_sheet("Objective Ranging") + _write_header_row(ws_obj, ["Variable", "Obj Lower", "Obj Current", "Obj Upper"]) + _freeze_and_autosize(ws_obj) + + obj_ranges = result.get("obj_ranges", {}) + row = 2 + for var_name, obj_range in obj_ranges.items(): + ws_obj.cell(row=row, column=1, value=var_name) + ws_obj.cell(row=row, column=2, value=obj_range.get("lower") if isinstance(obj_range, dict) else None) + ws_obj.cell(row=row, column=3, value=obj_range.get("current") if isinstance(obj_range, dict) else None) + ws_obj.cell(row=row, column=4, value=obj_range.get("upper") if isinstance(obj_range, dict) else None) + row += 1 + + return _to_bytes(wb) diff --git a/src/export/pdf.py b/src/export/pdf.py new file mode 100644 index 0000000..0f672bb --- /dev/null +++ b/src/export/pdf.py @@ -0,0 +1,254 @@ +""" +PDF report builders for Pro-tier export feature. +Uses ReportLab Platypus to produce branded PDF documents. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from io import BytesIO +from typing import Any + +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import ( + Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, PageBreak +) + +# ── Brand colours ────────────────────────────────────────────────────────────── +NAVY = colors.HexColor("#1F3864") +LIGHT_GREY = colors.HexColor("#F2F2F2") +WHITE = colors.white + + +# ── Style factory ────────────────────────────────────────────────────────────── + +def _make_styles(): + base = getSampleStyleSheet() + return { + "title": ParagraphStyle( + "sim2sim_title", + fontName="Helvetica-Bold", + fontSize=18, + textColor=NAVY, + spaceAfter=4, + alignment=TA_LEFT, + ), + "subtitle": ParagraphStyle( + "sim2sim_subtitle", + fontName="Helvetica", + fontSize=9, + textColor=colors.grey, + spaceAfter=16, + alignment=TA_LEFT, + ), + "section": ParagraphStyle( + "sim2sim_section", + fontName="Helvetica-Bold", + fontSize=14, + textColor=NAVY, + spaceBefore=14, + spaceAfter=6, + ), + "body": ParagraphStyle( + "sim2sim_body", + fontName="Helvetica", + fontSize=10, + textColor=colors.black, + spaceAfter=6, + ), + "note": ParagraphStyle( + "sim2sim_note", + fontName="Helvetica-Oblique", + fontSize=9, + textColor=colors.grey, + spaceAfter=4, + ), + } + + +_TABLE_STYLE = TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -1), 10), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, LIGHT_GREY]), + ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), +]) + + +def _format_value(v: Any) -> str: + if v is None: + return "—" + if isinstance(v, float): + return f"{v:.4f}" + return str(v) + + +def _footer_canvas(canvas, doc): + """Draw footer on every page: page number + site URL.""" + canvas.saveState() + canvas.setFont("Helvetica", 8) + canvas.setFillColor(colors.grey) + footer_text = f"Page {doc.page} | sim2sim.app" + canvas.drawRightString(A4[0] - 2 * cm, 1.2 * cm, footer_text) + canvas.restoreState() + + +def _build_doc(model_name: str, sections: list) -> bytes: + """Render a document with header + given section flowables.""" + buf = BytesIO() + doc = SimpleDocTemplate( + buf, + pagesize=A4, + leftMargin=2 * cm, rightMargin=2 * cm, + topMargin=2.5 * cm, bottomMargin=2.5 * cm, + title=f"Sim2Sim — {model_name}", + author="sim2sim.app", + ) + styles = _make_styles() + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + story = [ + Paragraph(f"Sim2Sim — {model_name}", styles["title"]), + Paragraph(f"Generated {timestamp}", styles["subtitle"]), + ] + story.extend(sections) + + doc.build(story, onFirstPage=_footer_canvas, onLaterPages=_footer_canvas) + return buf.getvalue() + + +def _params_section(params: dict, styles: dict) -> list: + """Build an Inputs section flowable list.""" + rows = [["Parameter", "Value"]] + for k, v in params.items(): + if v is None: + continue + rows.append([str(k), _format_value(v)]) + table = Table(rows, colWidths=[8 * cm, 9 * cm]) + table.setStyle(_TABLE_STYLE) + return [Paragraph("Inputs", styles["section"]), table, Spacer(1, 0.4 * cm)] + + +def _results_section(result: dict, styles: dict, skip_lists: bool = True) -> list: + """Build a Results section flowable list.""" + rows = [["Metric", "Value"]] + for k, v in result.items(): + if v is None: + continue + if skip_lists and isinstance(v, list): + continue + rows.append([str(k), _format_value(v)]) + table = Table(rows, colWidths=[8 * cm, 9 * cm]) + table.setStyle(_TABLE_STYLE) + return [Paragraph("Results", styles["section"]), table, Spacer(1, 0.4 * cm)] + + +def _notes_section(result: dict, styles: dict) -> list: + """Build a Notes section if notes present.""" + notes = result.get("notes") + if not notes: + return [] + return [ + Paragraph("Notes", styles["section"]), + Paragraph(str(notes), styles["note"]), + Spacer(1, 0.4 * cm), + ] + + +# ── Public builders ──────────────────────────────────────────────────────────── + +def build_queuing_pdf(result: dict, params: dict) -> bytes: + """Build a branded PDF for queuing analysis results.""" + model_name = f"Queuing Analysis ({result.get('model', 'Queue')})" + styles = _make_styles() + sections = ( + _params_section(params, styles) + + _results_section(result, styles, skip_lists=True) + + _notes_section(result, styles) + ) + return _build_doc(model_name, sections) + + +def build_inventory_pdf(result: dict, params: dict, model_kind: str) -> bytes: + """Build a branded PDF for inventory model results.""" + label_map = { + "eoq": "EOQ — Economic Order Quantity", + "eoq_backorder": "EOQ with Backorders", + "epq": "EPQ — Economic Production Quantity", + "newsvendor": "Newsvendor Model", + "reorder_point": "Reorder Point Model", + "base_stock": "Base Stock Model", + } + model_name = f"Inventory — {label_map.get(model_kind, model_kind.upper())}" + styles = _make_styles() + sections = ( + _params_section(params, styles) + + _results_section(result, styles, skip_lists=True) + + _notes_section(result, styles) + ) + return _build_doc(model_name, sections) + + +def build_lp_pdf(result: dict, params: dict) -> bytes: + """Build a branded PDF for LP optimization results.""" + model_name = "Linear Programming — Optimization" + styles = _make_styles() + + # Solution summary table + sol_rows = [["Item", "Value"]] + sol_rows.append(["Status", result.get("status", "")]) + sol_rows.append(["Optimal Value", _format_value(result.get("optimal_value"))]) + sol_table = Table(sol_rows, colWidths=[8 * cm, 9 * cm]) + sol_table.setStyle(_TABLE_STYLE) + + # Variables table + variables = result.get("variables", {}) + reduced_costs = result.get("reduced_costs", {}) + var_rows = [["Variable", "Value", "Reduced Cost"]] + for name, value in variables.items(): + var_rows.append([name, _format_value(value), _format_value(reduced_costs.get(name))]) + var_table = Table(var_rows, colWidths=[5 * cm, 6 * cm, 6 * cm]) + var_table.setStyle(_TABLE_STYLE) + + # Constraints table + shadow_prices = result.get("shadow_prices", {}) + slacks = result.get("slacks", {}) + binding_constraints = result.get("binding_constraints", []) + con_rows = [["Constraint", "Shadow Price", "Slack", "Binding"]] + for name in shadow_prices: + con_rows.append([ + name, + _format_value(shadow_prices.get(name)), + _format_value(slacks.get(name)), + "Y" if name in binding_constraints else "N", + ]) + con_table = Table(con_rows, colWidths=[5 * cm, 4 * cm, 4 * cm, 4 * cm]) + con_table.setStyle(_TABLE_STYLE) + + sections = ( + _params_section(params, styles) + + [ + Paragraph("Solution", styles["section"]), + sol_table, + Spacer(1, 0.4 * cm), + Paragraph("Variables", styles["section"]), + var_table, + Spacer(1, 0.4 * cm), + Paragraph("Constraints", styles["section"]), + con_table, + Spacer(1, 0.4 * cm), + ] + + _notes_section(result, styles) + ) + return _build_doc(model_name, sections) diff --git a/src/models/inventory.py b/src/models/inventory.py index de3e82b..3612b44 100644 --- a/src/models/inventory.py +++ b/src/models/inventory.py @@ -282,27 +282,32 @@ class ReorderPointResult: def solve_reorder_point( - D: float, # annual demand - L: float, # lead time (years) - sigma_d: float, # std dev of daily demand (use same time unit as L) - K: float, # ordering cost - c: float, # unit cost - i: float, # holding rate + D: float, # annual demand (units/year) + L_days: float, # lead time in DAYS + sigma_d: float, # std dev of DAILY demand (same time unit as L_days) + K: float, # ordering cost ($/order) + c: float, # unit cost ($/unit) + i: float, # holding rate (fraction/year) service_level: float = 0.95, # target cycle-service level ) -> ReorderPointResult: """ Continuous-review (Q, r) inventory policy. + Units: annual demand D is converted to a daily rate (D/365) for the + lead-time demand calculation, ensuring lead-time-day and daily-sigma + units agree. Holding cost h = i·c is annual, so the EOQ uses annual D. + Order quantity Q = EOQ (Wilson formula). - Reorder point r = D·L + z·σ_L where σ_L = σ_d·√L (annual units). + Reorder point r = (D/365)·L_days + z·σ_L, σ_L = σ_d·√L_days. The cycle-service level P(no stockout) = Φ(z). Safety stock SS = z·σ_L trades holding cost against stockout risk. """ h = i * c - Q = math.sqrt(2.0 * K * D / h) # EOQ order quantity - mu_L = D * L # mean demand during lead time - sigma_L = sigma_d * math.sqrt(L) # std dev demand during lead time + Q = math.sqrt(2.0 * K * D / h) # EOQ order quantity (annual) + D_day = D / 365.0 # daily demand rate + mu_L = D_day * L_days # mean demand during lead time (units) + sigma_L = sigma_d * math.sqrt(L_days) # std dev demand during lead time (units) z = stats.norm.ppf(service_level) SS = z * sigma_L r = mu_L + SS @@ -336,26 +341,30 @@ class BaseStockResult: def solve_base_stock( - D: float, # annual demand rate - L: float, # replenishment lead time (years) - sigma_d: float, # std dev of daily demand - c: float, # unit cost - i: float, # holding rate + D: float, # annual demand rate (units/year) + L_days: float, # replenishment lead time in DAYS + sigma_d: float, # std dev of DAILY demand + c: float, # unit cost + i: float, # annual holding rate service_level: float = 0.95, ) -> BaseStockResult: """ Base-stock (order-up-to) policy for single-item, stochastic demand. - S* = μ_L + z·σ_L (same expression as reorder point). + Units: lead-time is in days and daily sigma is supplied directly, so all + lead-time quantities (μ_L, σ_L) are in CONSISTENT day-based units. + + S* = μ_L + z·σ_L. Each period, order enough to bring inventory position back to S*. - E[backorders] = σ_L · L(z) where L(z) = φ(z) − z·(1−Φ(z)) is the - standard normal loss function. - Fill rate ≈ 1 − E[B]/D (Type-II service level). + E[backorders per cycle] = σ_L · L(z) where + L(z) = φ(z) − z·(1−Φ(z)) is the standard-normal loss function. + Type-II fill rate = 1 − E[B per cycle] / E[demand per cycle] = 1 − σ_L·L(z)/μ_L. """ h = i * c - mu_L = D * L - sigma_L = sigma_d * math.sqrt(L) + D_day = D / 365.0 + mu_L = D_day * L_days + sigma_L = sigma_d * math.sqrt(L_days) z = stats.norm.ppf(service_level) SS = z * sigma_L S = mu_L + SS @@ -364,12 +373,15 @@ def solve_base_stock( Phi_z = stats.norm.cdf(z) L_z = phi_z - z * (1.0 - Phi_z) # standard normal loss function - E_B = sigma_L * L_z + E_B = sigma_L * L_z # expected backorders per cycle (units) E_inv_precise = (S - mu_L) * Phi_z + sigma_L * phi_z - fill_rate = max(0.0, 1.0 - E_B * D / D) if D > 0 else 1.0 - # Type-II fill rate: 1 − E[B] / (demand per period) - # For annual: fill_rate = 1 − E_B/D (E_B and D in same annual units) - fill_rate = float(np.clip(1.0 - E_B / D, 0.0, 1.0)) if D > 0 else 1.0 + + # Type-II (fill-rate) service level. + # The standard expression for a base-stock policy with normal lead-time + # demand is: beta = 1 - E[B per cycle] / E[demand per cycle] + # With a continuous-review base-stock policy the demand per cycle equals + # the lead-time demand mu_L (one cycle = one lead-time interval), so: + fill_rate = float(np.clip(1.0 - E_B / mu_L, 0.0, 1.0)) if mu_L > 0 else 1.0 annual_hold = h * E_inv_precise return BaseStockResult( diff --git a/src/models/queuing.py b/src/models/queuing.py index 6c58357..1baf33b 100644 --- a/src/models/queuing.py +++ b/src/models/queuing.py @@ -42,6 +42,15 @@ class QueuingResult: # ── Internal helpers ────────────────────────────────────────────────────────── +def _little_law_residual(L: float, lam: float, W: float) -> float: + """Relative residual |L − λW| / max(|L|, 1). + Using a relative scale keeps the diagnostic meaningful at high + utilisation where L can be very large in absolute terms. + Values near 0 confirm Little's Law holds for this model. + """ + return abs(L - lam * W) / max(abs(L), 1.0) + + def _erlang_c(c: int, a: float) -> float: """ Erlang-C probability C(c, a) = P(arriving customer must wait). @@ -89,7 +98,7 @@ def solve_mm1(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/M/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -110,7 +119,7 @@ def solve_mmc(lam: float, mu: float, c: int) -> QueuingResult: return QueuingResult( model=f"M/M/{c}", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=ec, prob_dist=_mmc_prob_dist(c, a, P0), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -126,7 +135,7 @@ def solve_md1(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/D/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -146,7 +155,7 @@ def solve_mg1(lam: float, mu: float, cs_sq: float) -> QueuingResult: return QueuingResult( model=f"M/G/1 (Cs²={cs_sq:.2f})", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -192,7 +201,7 @@ def solve_mmck(lam: float, mu: float, c: int, K: int) -> QueuingResult: return QueuingResult( model=f"M/M/{c}/{K}", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=None, prob_dist=prob_dist, - little_law_check=abs(L - lam_eff * W), + little_law_check=_little_law_residual(L, lam_eff, W), blocking_prob=P_K, effective_lam=lam_eff, ) @@ -229,7 +238,7 @@ def solve_mm1k(lam: float, mu: float, K: int) -> QueuingResult: return QueuingResult( model=f"M/M/1/{K}", utilization=util, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=None, prob_dist=prob_dist, - little_law_check=abs(L - lam_eff * W), + little_law_check=_little_law_residual(L, lam_eff, W), blocking_prob=P_K, effective_lam=lam_eff, ) @@ -258,7 +267,7 @@ def solve_mminf(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/M/∞", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=0.0, prob_dist=prob_dist, - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), notes="No queuing: every customer finds a free server immediately.", ) @@ -289,7 +298,7 @@ def solve_gg1(lam: float, mu: float, ca_sq: float, cs_sq: float) -> QueuingResul model=f"G/G/1 (Ca²={ca_sq:.2f}, Cs²={cs_sq:.2f})", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), notes="Kingman heavy-traffic approximation. Most accurate when ρ > 0.7.", ) @@ -322,7 +331,7 @@ def solve_bulk_mm1(lam: float, mu: float, batch_size: int) -> QueuingResult: return QueuingResult( model=f"M[{b}]/M/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam_ind * W), + little_law_check=_little_law_residual(L, lam_ind, W), notes=f"Batches of {b} arrive at rate λ={lam}. " f"Individual arrival rate = {lam_ind:.2f}.", ) diff --git a/src/models/simulation.py b/src/models/simulation.py index fc7be55..657c71c 100644 --- a/src/models/simulation.py +++ b/src/models/simulation.py @@ -63,8 +63,9 @@ def _run_single_replication( we can compute each customer's arrival, queue-entry, and departure analytically without maintaining a full event heap. """ - # Burn-in: discard first 10% of customers to reach steady state - n_warmup = max(1, n_customers // 10) + # Burn-in: discard first 20% of customers (or at least 100) to reach + # steady state. 10% was too aggressive for ρ > 0.8 systems. + n_warmup = max(100, n_customers // 5) n_total = n_customers + n_warmup server_free = np.zeros(c) # time each server becomes free @@ -73,7 +74,7 @@ def _run_single_replication( wait_times = [] # Wq per customer (post warmup) sojourn = [] # W per customer (post warmup) busy_total = 0.0 # total server-busy time (post warmup) - sim_start = None # clock at end of warmup + sim_start = None # clock at start of measurement window for k in range(n_total): # Inter-arrival: Exponential(lam) @@ -95,8 +96,9 @@ def _run_single_replication( wq = start_svc - arrival # wait in queue w = depart - arrival # sojourn time - if k == n_warmup - 1: - # Mark steady-state clock start and reset server busyness + if k == n_warmup: + # First post-warmup customer: this arrival starts the + # measurement window for utilisation accounting. sim_start = arrival busy_total = 0.0 diff --git a/static/account.html b/static/account.html new file mode 100644 index 0000000..7067ace --- /dev/null +++ b/static/account.html @@ -0,0 +1,128 @@ + + + + + + + Account — Sim2Sim + + + + + + + + +
+ +
+ +
+ +
+ + + + + + + diff --git a/static/css/billing.css b/static/css/billing.css new file mode 100644 index 0000000..a81863a --- /dev/null +++ b/static/css/billing.css @@ -0,0 +1,298 @@ +/* ════════════════════════════════════════════════════════ + Sim2Sim — Billing pages (pricing + account) + ════════════════════════════════════════════════════════ */ + +.billing-main { + max-width: 1080px; + margin: 0 auto; + padding: var(--space-12) var(--space-6); +} + +.billing-hero { + text-align: center; + margin-bottom: var(--space-12); +} +.billing-hero h1 { + font-size: clamp(2rem, 4vw, 2.75rem); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + margin-bottom: var(--space-3); +} +.billing-hero p { + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* ── Pricing grid ── */ +.pricing-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-6); + margin-bottom: var(--space-12); +} + +.price-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-8) var(--space-6); + display: flex; + flex-direction: column; + position: relative; + transition: border-color .15s ease, transform .15s ease; +} +.price-card:hover { border-color: var(--accent); } +.price-card.featured { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), var(--shadow-lg); + transform: translateY(-4px); +} +.price-badge { + position: absolute; + top: -12px; left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #061226; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + padding: 4px 12px; + border-radius: 999px; + text-transform: uppercase; +} +.price-card-head { text-align: center; margin-bottom: var(--space-6); } +.price-card-head h2 { + font-size: 1.05rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + font-weight: 600; + margin-bottom: var(--space-3); +} +.price { display: flex; align-items: baseline; justify-content: center; gap: var(--space-2); margin-bottom: var(--space-3); } +.price-amount { font-size: 2.75rem; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; } +.price-period { color: var(--text-muted); font-size: 0.85rem; } +.price-tagline { color: var(--text-secondary); font-size: 0.92rem; } + +.price-features { + list-style: none; + padding: 0; + margin: 0 0 var(--space-6) 0; + flex-grow: 1; + font-size: 0.92rem; +} +.price-features li { + padding: var(--space-2) 0; + color: var(--text-primary); + border-bottom: 1px solid var(--border-light); + display: flex; align-items: flex-start; +} +.price-features li:last-child { border-bottom: none; } +.price-features li::before { + content: "✓"; + color: var(--success); + margin-right: var(--space-2); + font-weight: 600; + flex-shrink: 0; +} +.price-features li.muted { color: var(--text-muted); } +.price-features li.muted::before { content: "✗"; color: var(--text-muted); } +.price-features strong { color: var(--text-primary); } + +.price-cta { + display: block; + text-align: center; + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + font-weight: 600; + font-size: 0.95rem; + text-decoration: none; + transition: background .15s ease, color .15s ease; + margin-bottom: var(--space-3); +} +.price-cta.primary { + background: var(--accent-dark); + color: #fff; +} +.price-cta.primary:hover { background: var(--accent); } +.price-cta.secondary { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); +} +.price-cta.secondary:hover { border-color: var(--accent); color: var(--accent); } +.price-fine { font-size: 0.78rem; color: var(--text-muted); text-align: center; } + +/* ── FAQ ── */ +.faq { + max-width: 720px; + margin: 0 auto; +} +.faq h2 { + text-align: center; + margin-bottom: var(--space-6); + color: var(--text-primary); + font-size: 1.5rem; +} +.faq details { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-4) var(--space-5); + margin-bottom: var(--space-3); +} +.faq details[open] { border-color: var(--accent); } +.faq summary { + cursor: pointer; + font-weight: 500; + color: var(--text-primary); + list-style: none; +} +.faq summary::-webkit-details-marker { display: none; } +.faq summary::after { content: " +"; color: var(--text-muted); } +.faq details[open] summary::after { content: " −"; color: var(--accent); } +.faq details p { + margin-top: var(--space-3); + color: var(--text-secondary); + line-height: 1.6; +} + +/* ── Footer ── */ +.billing-footer { + text-align: center; + padding: var(--space-8) var(--space-6); + color: var(--text-muted); + font-size: 0.85rem; + border-top: 1px solid var(--border-light); +} +.billing-footer a { color: var(--accent); } + +/* ── Account page ── */ +.account-card { + max-width: 560px; + margin: 0 auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-8); +} +.account-card h1 { + font-size: 1.6rem; + margin-bottom: var(--space-6); + color: var(--text-primary); +} + +.status-block { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-5); + margin-bottom: var(--space-6); +} +.status-block.status-active { border-color: var(--success); background: rgba(63,185,80,.05); } +.status-line { color: var(--text-secondary); margin-bottom: var(--space-2); font-size: 0.95rem; } +.status-line:last-child { margin-bottom: 0; } +.status-line strong { color: var(--text-primary); display: inline-block; min-width: 100px; } +.status-line span { color: var(--text-primary); font-family: var(--font-mono); } + +.activate-form { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); } +.activate-form label { font-size: 0.9rem; color: var(--text-secondary); font-weight: 500; } +.activate-form input { + background: var(--bg-base); + border: 1px solid var(--border); + color: var(--text-primary); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 1rem; + letter-spacing: 0.05em; +} +.activate-form input:focus { outline: none; border-color: var(--accent); } +.activate-form button { padding: var(--space-3) var(--space-5); } + +.activate-msg { font-size: 0.9rem; min-height: 1.2em; color: var(--text-muted); } +.activate-msg.success { color: var(--success); } +.activate-msg.error { color: var(--danger); } + +.account-help { + background: var(--bg-elevated); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-3); + font-size: 0.9rem; +} +.account-help summary { + cursor: pointer; + color: var(--text-primary); + font-weight: 500; +} +.account-help p { color: var(--text-secondary); margin-top: var(--space-2); line-height: 1.6; } +.account-help a { color: var(--accent); } + +.link-danger { + background: none; + border: none; + color: var(--danger); + font-size: 0.85rem; + cursor: pointer; + padding: 0; + text-decoration: underline; +} +.link-danger:hover { color: #ff7269; } + +/* ── Upgrade modal (used by app.js) ── */ +.upgrade-modal-bg { + position: fixed; inset: 0; + background: rgba(0,0,0,.6); + display: flex; align-items: center; justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} +.upgrade-modal { + background: var(--bg-surface); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: var(--space-8); + max-width: 440px; + width: calc(100% - var(--space-8)); + box-shadow: var(--shadow-lg); + text-align: center; +} +.upgrade-modal h3 { font-size: 1.4rem; color: var(--text-primary); margin-bottom: var(--space-3); } +.upgrade-modal p { color: var(--text-secondary); margin-bottom: var(--space-6); line-height: 1.6; } +.upgrade-modal .modal-actions { display: flex; gap: var(--space-3); justify-content: center; } +.upgrade-modal a, .upgrade-modal button { + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: 1px solid var(--border); + background: transparent; + color: var(--text-primary); + font-size: 0.92rem; +} +.upgrade-modal a.cta-primary { + background: var(--accent-dark); + border-color: var(--accent-dark); + color: #fff; +} +.upgrade-modal a.cta-primary:hover { background: var(--accent); } + +/* ── Pro badge for app header ── */ +.tier-pill { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-left: var(--space-2); + vertical-align: middle; +} +.tier-pill.free { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); } +.tier-pill.pro { background: rgba(31,111,235,.15); color: var(--accent); border: 1px solid var(--accent-dark); } +.tier-pill.team { background: rgba(63,185,80,.15); color: var(--success); border: 1px solid var(--success); } diff --git a/static/css/style.css b/static/css/style.css index 12c7f3d..15191e6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,5 +1,5 @@ /* ════════════════════════════════════════════════════════ - Sim2Real — Dark Professional Theme + Sim2Sim — Dark Professional Theme Design tokens → component styles → layout → utilities ════════════════════════════════════════════════════════ */ diff --git a/static/index.html b/static/index.html index ddc869a..b8cb1b5 100644 --- a/static/index.html +++ b/static/index.html @@ -3,8 +3,8 @@ - - Sim2Real | Operations Research Platform + + Sim2Sim | Operations Research Platform @@ -22,9 +22,12 @@ - - - + + + + + + @@ -33,10 +36,12 @@
- Sim2Real + Sim2Sim Operations Research
@@ -237,8 +242,8 @@

Inventory Parameters

- - + + diff --git a/static/js/api.js b/static/js/api.js index 5f14001..6c491e9 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -1,5 +1,5 @@ /** - * Sim2Real API client — thin wrapper around fetch(). + * Sim2Sim API client — thin wrapper around fetch(). * * Security notes: * - All requests go to the same origin (/api/*) — no cross-origin calls. diff --git a/static/js/app.js b/static/js/app.js index 74e11fa..b13419c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -172,6 +172,10 @@ async function handleQueuing() { try { const data = await SimAPI.analyseQueue(params); renderQueuingResults(data, params, 'results-queuing'); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'queuing', result: data, params, + container: document.getElementById('results-queuing'), + }); triggerExplanation('Queuing (' + data.model + ')', params, summariseQueuing(data)); } catch(e) { showToast(e.detail ?? e.message, 'error'); } finally { setLoading(btn, false); } @@ -417,6 +421,10 @@ async function handleEOQ() { }; const data = await SimAPI.analyseEOQ(params); renderEOQResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'eoq', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('EOQ', params, { eoq: data.eoq, orders_per_year: data.orders_per_year, cycle_time_days: data.cycle_time_days, total_annual_cost: data.total_annual_cost }); } @@ -431,6 +439,10 @@ async function handleEOQBackorder() { }; const data = await SimAPI.post('/inventory/eoq-backorder', params); renderEOQBackorderResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'eoq_backorder', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('EOQ with Backorders', params, data); } @@ -444,6 +456,10 @@ async function handleEPQ() { }; const data = await SimAPI.post('/inventory/epq', params); renderEPQResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'epq', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('EPQ', params, data); } @@ -458,6 +474,10 @@ async function handleNewsvendor() { }; const data = await SimAPI.analyseNewsvendor(params); renderNewsvendorResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'newsvendor', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('Newsvendor', params, { critical_ratio: data.critical_ratio, optimal_quantity: data.optimal_quantity, expected_profit: data.expected_profit, fill_rate: data.fill_rate }); @@ -466,7 +486,7 @@ async function handleNewsvendor() { async function handleReorderPoint() { const params = { annual_demand: parseFloat(document.getElementById('rp-demand').value), - lead_time: parseFloat(document.getElementById('rp-lead').value), + lead_time_days: parseFloat(document.getElementById('rp-lead').value), demand_std_day: parseFloat(document.getElementById('rp-sigma').value), ordering_cost: parseFloat(document.getElementById('rp-k').value), unit_cost: parseFloat(document.getElementById('rp-c').value), @@ -475,13 +495,17 @@ async function handleReorderPoint() { }; const data = await SimAPI.post('/inventory/reorder-point', params); renderReorderPointResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'reorder_point', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('Reorder Point (Q,r) Policy', params, data); } async function handleBaseStock() { const params = { annual_demand: parseFloat(document.getElementById('rp-demand').value), - lead_time: parseFloat(document.getElementById('rp-lead').value), + lead_time_days: parseFloat(document.getElementById('rp-lead').value), demand_std_day: parseFloat(document.getElementById('rp-sigma').value), unit_cost: parseFloat(document.getElementById('rp-c').value), holding_rate: parseFloat(document.getElementById('rp-i').value), @@ -489,6 +513,10 @@ async function handleBaseStock() { }; const data = await SimAPI.post('/inventory/base-stock', params); renderBaseStockResults(data); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'inventory', modelKind: 'base_stock', result: data, params, + container: document.getElementById('results-inventory'), + }); triggerExplanation('Base Stock Policy', params, data); } @@ -717,6 +745,11 @@ async function handleLP() { const params = { objective, c_obj, A_ub, b_ub, variable_names, constraint_names }; const data = await SimAPI.post('/optimize/lp', params); renderLPResults(data, variable_names, constraint_names, c_obj, objective); + if (window.Sim2SimExports) window.Sim2SimExports.attach({ + kind: 'lp', result: data, + params: { c_obj, A_ub, b_ub, variable_names, constraint_names, objective }, + container: document.getElementById('results-optimization'), + }); triggerExplanation('Linear Programming', params, { status: data.status, optimal_value: data.optimal_value, variables: data.variables, shadow_prices: data.shadow_prices, diff --git a/static/js/charts.js b/static/js/charts.js index 216a015..c277329 100644 --- a/static/js/charts.js +++ b/static/js/charts.js @@ -1,5 +1,5 @@ /** - * Chart.js helpers for Sim2Real. + * Chart.js helpers for Sim2Sim. * All charts use the same dark-theme defaults derived from CSS variables. */ diff --git a/static/js/exports.js b/static/js/exports.js new file mode 100644 index 0000000..b246c94 --- /dev/null +++ b/static/js/exports.js @@ -0,0 +1,179 @@ +/** + * Sim2Sim — Export buttons + Pro gating. + * + * This file is loaded last (after api.js, license.js, app.js). It: + * 1. Updates the header tier pill based on license status. + * 2. Provides window.Sim2SimExports.attach({kind, result, params, container}) + * which injects "Download Excel" and "Download PDF" buttons into the + * given container. app.js will call this after rendering results. + * 3. Falls back to a "soft" mode where, if app.js never calls attach(), + * we observe DOM mutations and inject a generic export bar after each + * panel's results-area finishes rendering. We hide the bar if no + * result is yet computed. + * 4. Shows an "Upgrade to Pro" modal when a Free user clicks an export. + */ +(function () { + "use strict"; + + // ── Tier pill ───────────────────────────────────────────────────────────── + async function refreshPill() { + const pill = document.getElementById("tier-pill"); + if (!pill) return; + const status = await window.Sim2SimLicense.fetchStatus(); + if (status.valid && status.tier === "team") { + pill.textContent = "Team"; + pill.className = "tier-pill team"; + } else if (status.valid && status.tier === "pro") { + pill.textContent = "Pro"; + pill.className = "tier-pill pro"; + } else { + pill.textContent = "Free"; + pill.className = "tier-pill free"; + } + } + + // ── Upgrade modal ───────────────────────────────────────────────────────── + function showUpgradeModal(feature) { + if (document.getElementById("upgrade-modal-bg")) return; + const bg = document.createElement("div"); + bg.className = "upgrade-modal-bg"; + bg.id = "upgrade-modal-bg"; + bg.innerHTML = ` + + `; + document.body.appendChild(bg); + bg.addEventListener("click", (e) => { if (e.target === bg) bg.remove(); }); + document.getElementById("modal-close").addEventListener("click", () => bg.remove()); + } + + // ── Export download ─────────────────────────────────────────────────────── + async function downloadExport(path, body, filename) { + if (!window.Sim2SimLicense.isPro()) { + showUpgradeModal("Exporting results"); + return; + } + const key = window.Sim2SimLicense.getKey() || ""; + let resp; + try { + resp = await fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-License-Key": key, + }, + body: JSON.stringify(body), + }); + } catch (e) { + alert("Network error — could not reach the server."); + return; + } + if (resp.status === 403) { + showUpgradeModal("Exporting results"); + return; + } + if (!resp.ok) { + let detail = `HTTP ${resp.status}`; + try { detail = (await resp.json()).detail || detail; } catch (_) {} + alert("Export failed: " + detail); + return; + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + // ── attach() — called by app.js after each result render ────────────────── + function attach({ kind, result, params, container, modelKind }) { + if (!container) return; + // Remove any prior export bar in this container. + const prior = container.querySelector(".export-bar"); + if (prior) prior.remove(); + + const bar = document.createElement("div"); + bar.className = "export-bar"; + bar.innerHTML = ` + + + + `; + const isPro = window.Sim2SimLicense.isPro(); + bar.querySelectorAll(".btn-export").forEach((btn) => { + btn.classList.toggle("locked", !isPro); + btn.title = isPro ? "Download" : "Pro feature — click to upgrade"; + }); + bar.querySelector(".export-hint").textContent = + isPro ? "Pro feature included" : "🔒 Pro — upgrade to download"; + + container.appendChild(bar); + + const pathByKind = { + queuing: { xlsx: "/api/export/queuing/xlsx", pdf: "/api/export/queuing/pdf", file: "sim2sim-queuing" }, + inventory: { xlsx: "/api/export/inventory/xlsx", pdf: "/api/export/inventory/pdf", file: "sim2sim-inventory" }, + lp: { xlsx: "/api/export/lp/xlsx", pdf: "/api/export/lp/pdf", file: "sim2sim-lp" }, + }; + const cfg = pathByKind[kind]; + if (!cfg) return; + + bar.querySelectorAll(".btn-export").forEach((btn) => { + btn.addEventListener("click", () => { + const fmt = btn.dataset.fmt; + const body = kind === "inventory" + ? { result, params, model_kind: modelKind || "eoq" } + : { result, params }; + downloadExport(cfg[fmt], body, `${cfg.file}.${fmt}`); + }); + }); + } + + // ── Light styling for the export bar (kept here to avoid bloating style.css) ── + const style = document.createElement("style"); + style.textContent = ` + .export-bar { + display: flex; gap: var(--space-3, .75rem); align-items: center; + margin-top: var(--space-4, 1rem); padding: var(--space-3, .75rem) var(--space-4, 1rem); + background: var(--bg-elevated, #1f2937); + border: 1px solid var(--border, #30363d); + border-radius: var(--radius-md, 8px); + flex-wrap: wrap; + } + .btn-export { + background: var(--accent-dark, #1f6feb); color: #fff; + border: none; padding: 6px 14px; + border-radius: var(--radius-sm, 4px); + font-weight: 600; font-size: 0.85rem; cursor: pointer; + transition: background .15s ease; + } + .btn-export:hover { background: var(--accent, #58a6ff); } + .btn-export.locked { + background: var(--bg-hover, #2d3748); + color: var(--text-muted, #6e7681); + border: 1px dashed var(--border, #30363d); + } + .btn-export.locked:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); } + .export-hint { color: var(--text-muted, #6e7681); font-size: 0.8rem; margin-left: auto; } + `; + document.head.appendChild(style); + + // ── Expose ──────────────────────────────────────────────────────────────── + window.Sim2SimExports = { attach, showUpgradeModal, refreshPill }; + + // ── Bootstrap ───────────────────────────────────────────────────────────── + document.addEventListener("DOMContentLoaded", () => { + refreshPill(); + window.Sim2SimLicense.on("change", refreshPill); + }); +})(); diff --git a/static/js/license.js b/static/js/license.js new file mode 100644 index 0000000..c484cca --- /dev/null +++ b/static/js/license.js @@ -0,0 +1,116 @@ +/** + * Sim2Sim license client. + * + * Stores the license key in localStorage under `sim2sim_license_key`. + * Exposes window.Sim2SimLicense with: + * - getKey() → string|null + * - setKey(key) → void + * - clear() → void + * - fetchStatus() → Promise<{valid, tier, email, activation_count}> + * - activate(key) → Promise<{valid, tier, email, message?}> + * - isPro() → boolean (sync, last-known) + * - isTeam() → boolean (sync, last-known) + * - on(event, cb) → subscribe to "change" events + * + * Also exposes window.STRIPE_PRO_URL / STRIPE_TEAM_URL when set via + * window.SIM2SIM_CONFIG (set in HTML before this script loads), so the + * pricing CTAs can be configured by the user without touching this file. + */ +(function () { + const STORAGE_KEY = "sim2sim_license_key"; + const STATUS_CACHE_KEY = "sim2sim_license_status"; + + // Allow the host page (or a future config script) to inject Stripe URLs. + // SIM2SIM_CONFIG can also be populated by Replit env vars via the index + // template, but for now we read from localStorage as a dev convenience. + if (window.SIM2SIM_CONFIG) { + if (window.SIM2SIM_CONFIG.stripeProUrl) window.STRIPE_PRO_URL = window.SIM2SIM_CONFIG.stripeProUrl; + if (window.SIM2SIM_CONFIG.stripeTeamUrl) window.STRIPE_TEAM_URL = window.SIM2SIM_CONFIG.stripeTeamUrl; + } + + const listeners = []; + function emit() { + listeners.forEach((cb) => { + try { cb(getCachedStatus()); } catch (_) {} + }); + } + + function getKey() { + try { return localStorage.getItem(STORAGE_KEY); } catch (_) { return null; } + } + function setKey(key) { + try { localStorage.setItem(STORAGE_KEY, key); } catch (_) {} + } + function clear() { + try { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STATUS_CACHE_KEY); + } catch (_) {} + emit(); + } + + function getCachedStatus() { + try { + const raw = localStorage.getItem(STATUS_CACHE_KEY); + return raw ? JSON.parse(raw) : { valid: false }; + } catch (_) { return { valid: false }; } + } + function setCachedStatus(s) { + try { localStorage.setItem(STATUS_CACHE_KEY, JSON.stringify(s)); } catch (_) {} + emit(); + } + + async function fetchStatus() { + const key = getKey(); + if (!key) { + const s = { valid: false }; + setCachedStatus(s); + return s; + } + try { + const resp = await fetch("/api/billing/status", { + headers: { "X-License-Key": key }, + }); + const data = await resp.json(); + setCachedStatus(data); + return data; + } catch (e) { + console.warn("license status fetch failed", e); + return getCachedStatus(); + } + } + + async function activate(key) { + try { + const resp = await fetch("/api/billing/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + const data = await resp.json(); + if (data.valid) { + setKey(key); + setCachedStatus(data); + } + return data; + } catch (e) { + return { valid: false, message: "Network error — try again." }; + } + } + + function isPro() { const s = getCachedStatus(); return !!(s.valid && (s.tier === "pro" || s.tier === "team")); } + function isTeam() { const s = getCachedStatus(); return !!(s.valid && s.tier === "team"); } + + function on(_evt, cb) { listeners.push(cb); } + + window.Sim2SimLicense = { + getKey, setKey, clear, + fetchStatus, activate, + isPro, isTeam, + on, + getCachedStatus, + }; + + // Refresh status on load so every page sees fresh state. + fetchStatus().catch(() => {}); +})(); diff --git a/static/pricing.html b/static/pricing.html new file mode 100644 index 0000000..389c1a4 --- /dev/null +++ b/static/pricing.html @@ -0,0 +1,142 @@ + + + + + + + Pricing — Sim2Sim + + + + + + + + +
+ +
+ +
+
+

Lifetime access. No subscription.

+

Pay once, own it forever. Every tier includes future updates and improvements.

+
+ +
+ +
+
+

Free

+
+ $0 + forever +
+

Great for learning and homework.

+
+
    +
  • All 9 queuing models (M/M/1, M/M/c, M/G/1, …)
  • +
  • EOQ and Newsvendor inventory models
  • +
  • Monte Carlo simulation
  • +
  • Linear programming + CPM scheduling
  • +
  • 5 AI explanations per day
  • +
  • No exports
  • +
  • Standard rate limit
  • +
+ Use Free +
+ + + + + +
+
+

Team

+
+ $249 + one-time, lifetime +
+

For consultants and small teams.

+
+
    +
  • Everything in Pro
  • +
  • Branded PDF reports (your logo + footer)
  • +
  • API access with token authentication
  • +
  • Commercial-use license
  • +
  • Up to 5 seats — share the key with your team
  • +
  • Priority email support
  • +
  • Lifetime updates
  • +
+ Buy Team — $249 +

License key emailed instantly after checkout.

+
+
+ +
+

FAQ

+
Is this really lifetime? +

Yes. Pay once, use it forever. All future updates included.

+
What if my browser clears storage? +

Your license key was emailed to you — just paste it on the + Account page again. You can re-activate as many times as needed.

+
Can I use Pro on multiple computers? +

Yes. The key is yours — use it on every machine and browser you own.

+
Do you offer refunds? +

Yes. Email us within 14 days and we'll refund, no questions asked.

+
Do I have to log in? +

No login. After purchase you'll get a license key by email — paste it once and you're done.

+
+
+ + + + + + + diff --git a/tests/test_billing.py b/tests/test_billing.py new file mode 100644 index 0000000..0a3101d --- /dev/null +++ b/tests/test_billing.py @@ -0,0 +1,351 @@ +""" +Tests for src/billing/* — license generation, activation, status, webhook +signature verification, and the end-to-end webhook → license-creation flow. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +# ── Shared fixtures ─────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _isolated_db(tmp_path, monkeypatch): + """ + Every test gets its own SQLite file plus a known license secret. + We also force development-mode env so dev bypasses don't trip prod paths. + """ + db_file = tmp_path / "test_sim2sim.db" + monkeypatch.setenv("SIM2SIM_DB", str(db_file)) + monkeypatch.setenv("SIM2SIM_LICENSE_SECRET", "test-secret-key") + monkeypatch.setenv("ENVIRONMENT", "development") + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_secret") + # Disable Resend so tests never make network calls. + monkeypatch.delenv("RESEND_API_KEY", raising=False) + + # Re-init the DB module against the new path. + from src.billing import db + db.init_db() + yield + if db_file.exists(): + db_file.unlink() + + +@pytest.fixture +def client(): + # main.py reads env at import time for CORS, so we re-import to pick up env. + import importlib + import main as main_mod + importlib.reload(main_mod) + return TestClient(main_mod.app) + + +# ── Key generation + validation ─────────────────────────────────────────────── + + +class TestKeyFormat: + def test_generate_pro_key_format(self): + from src.billing import licenses + key = licenses.generate_key("pro") + assert key.startswith("LIC-") + parts = key.split("-") + assert len(parts) == 5 + assert all(len(p) == 4 for p in parts[1:]) + + def test_generate_team_key_format(self): + from src.billing import licenses + key = licenses.generate_key("team") + assert licenses._key_is_well_formed(key) + + def test_generate_invalid_tier_raises(self): + from src.billing import licenses + with pytest.raises(ValueError): + licenses.generate_key("enterprise") + + def test_keys_are_unique(self): + from src.billing import licenses + keys = {licenses.generate_key("pro") for _ in range(50)} + assert len(keys) == 50 + + def test_malformed_key_rejected(self): + from src.billing import licenses + assert not licenses._key_is_well_formed("not-a-key") + assert not licenses._key_is_well_formed("LIC-XXXX") + assert not licenses._key_is_well_formed("LIC-XXXX-XXXX-XXXX-XXXX-XXXX") + # 'I' is not in our alphabet + assert not licenses._key_is_well_formed("LIC-IIII-IIII-IIII-IIII") + + +# ── Create / validate / activate licenses ───────────────────────────────────── + + +class TestLicenseLifecycle: + def test_create_and_validate_pro(self): + from src.billing import licenses + info = licenses.create_license(tier="pro", email="a@b.com") + validated = licenses.validate_license(info.key) + assert validated is not None + assert validated.tier == "pro" + assert validated.email == "a@b.com" + + def test_create_idempotent_on_session_id(self): + from src.billing import licenses + first = licenses.create_license( + tier="pro", email="a@b.com", stripe_session_id="cs_test_1", + ) + second = licenses.create_license( + tier="pro", email="a@b.com", stripe_session_id="cs_test_1", + ) + assert first.key == second.key + + def test_unknown_key_fails_validation(self): + from src.billing import licenses + assert licenses.validate_license("LIC-0000-0000-0000-0000") is None + + def test_activate_records_count(self): + from src.billing import licenses + info = licenses.create_license(tier="pro", email="a@b.com") + assert info.activation_count == 0 + a = licenses.activate_license(info.key) + assert a is not None and a.activation_count == 1 + b = licenses.activate_license(info.key) + assert b is not None and b.activation_count == 2 + + +# ── HTTP: activate / status ─────────────────────────────────────────────────── + + +class TestBillingEndpoints: + def test_activate_valid_key(self, client): + from src.billing import licenses + info = licenses.create_license(tier="pro", email="paid@example.com") + resp = client.post("/api/billing/activate", json={"key": info.key}) + assert resp.status_code == 200 + body = resp.json() + assert body["valid"] is True + assert body["tier"] == "pro" + assert body["email"] == "paid@example.com" + + def test_activate_invalid_key(self, client): + resp = client.post( + "/api/billing/activate", + json={"key": "LIC-ZZZZ-ZZZZ-ZZZZ-ZZZZ"}, + ) + assert resp.status_code == 200 + assert resp.json()["valid"] is False + + def test_status_with_valid_header(self, client): + from src.billing import licenses + info = licenses.create_license(tier="team", email="t@example.com") + resp = client.get("/api/billing/status", headers={"X-License-Key": info.key}) + assert resp.status_code == 200 + body = resp.json() + assert body["valid"] is True + assert body["tier"] == "team" + + def test_status_without_header(self, client): + resp = client.get("/api/billing/status") + assert resp.status_code == 200 + assert resp.json()["valid"] is False + + +# ── HTTP: Pro gate honours real keys ────────────────────────────────────────── + + +class TestProGate: + """Make sure export endpoints accept a real Pro key (not just 'dev').""" + + def test_export_with_real_pro_key(self, client, monkeypatch): + # Force non-dev gating so the dev bypass is OFF. + monkeypatch.setenv("ENVIRONMENT", "production") + monkeypatch.setenv("ALLOWED_ORIGIN", "https://example.com") + + # Re-import main with prod env. + import importlib + import main as main_mod + importlib.reload(main_mod) + prod_client = TestClient(main_mod.app) + + from src.billing import licenses + info = licenses.create_license(tier="pro", email="paid@example.com") + + # Bogus key → 403 + body = { + "result": { + "model": "M/M/1", "utilization": 0.5, "L": 1.0, "Lq": 0.5, + "W": 1.0, "Wq": 0.5, "P0": 0.5, "P_wait": 0.5, + "prob_dist": [], "little_law_check": True, + }, + "params": {"arrival_rate": 1.0, "service_rate": 2.0}, + } + bogus = prod_client.post( + "/api/export/queuing/xlsx", json=body, + headers={"X-License-Key": "LIC-0000-0000-0000-0000"}, + ) + assert bogus.status_code == 403 + + # Real key → 200 with xlsx mime type + good = prod_client.post( + "/api/export/queuing/xlsx", json=body, + headers={"X-License-Key": info.key}, + ) + assert good.status_code == 200 + assert "spreadsheetml" in good.headers.get("content-type", "") + + +# ── Webhook signature verification ──────────────────────────────────────────── + + +def _sign(payload: bytes, secret: str, ts: int) -> str: + signed = f"{ts}.".encode("utf-8") + payload + sig = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() + return f"t={ts},v1={sig}" + + +class TestWebhookSignature: + def test_valid_signature(self): + from src.billing.webhook import verify_signature + secret = "whsec_test_secret" + payload = b'{"hello":"world"}' + ts = int(time.time()) + header = _sign(payload, secret, ts) + assert verify_signature(payload, header, secret, now=ts) is True + + def test_tampered_payload_fails(self): + from src.billing.webhook import verify_signature + secret = "whsec_test_secret" + ts = int(time.time()) + header = _sign(b'{"hello":"world"}', secret, ts) + assert verify_signature(b'{"hello":"evil"}', header, secret, now=ts) is False + + def test_stale_timestamp_fails(self): + from src.billing.webhook import verify_signature + secret = "whsec_test_secret" + old_ts = int(time.time()) - 3600 + header = _sign(b"{}", secret, old_ts) + assert verify_signature(b"{}", header, secret, now=int(time.time())) is False + + def test_missing_signature_fails(self): + from src.billing.webhook import verify_signature + assert verify_signature(b"{}", "", "secret", now=1) is False + + +# ── End-to-end webhook flow ─────────────────────────────────────────────────── + + +class TestWebhookFlow: + def _build_event(self, *, amount_total: int = 4900, email: str = "buyer@example.com"): + return { + "type": "checkout.session.completed", + "data": {"object": { + "id": "cs_test_abc123", + "amount_total": amount_total, + "customer_details": {"email": email}, + "metadata": {}, + }}, + } + + def test_pro_purchase_creates_license(self, client): + evt = self._build_event(amount_total=4900) + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + + resp = client.post( + "/api/billing/webhook", + content=payload, + headers={ + "stripe-signature": header, + "content-type": "application/json", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["action"] == "license_created" + assert body["license_key"].startswith("LIC-") + + # The license now validates. + from src.billing import licenses + info = licenses.validate_license(body["license_key"]) + assert info is not None and info.tier == "pro" + + def test_team_purchase_creates_team_license(self, client): + evt = self._build_event(amount_total=24900, email="team@example.com") + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + resp = client.post( + "/api/billing/webhook", + content=payload, + headers={"stripe-signature": header}, + ) + assert resp.status_code == 200 + assert resp.json()["action"] == "license_created" + from src.billing import licenses + info = licenses.validate_license(resp.json()["license_key"]) + assert info is not None and info.tier == "team" + + def test_webhook_idempotent(self, client): + evt = self._build_event(amount_total=4900) + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + + r1 = client.post("/api/billing/webhook", content=payload, + headers={"stripe-signature": header}) + r2 = client.post("/api/billing/webhook", content=payload, + headers={"stripe-signature": header}) + assert r1.json()["license_key"] == r2.json()["license_key"] + + def test_webhook_ignores_unknown_event(self, client): + evt = {"type": "customer.created", "data": {"object": {}}} + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + resp = client.post("/api/billing/webhook", content=payload, + headers={"stripe-signature": header}) + assert resp.status_code == 200 + assert resp.json()["action"].startswith("ignored:") + + def test_webhook_skips_when_email_missing(self, client): + evt = { + "type": "checkout.session.completed", + "data": {"object": { + "id": "cs_test_no_email", "amount_total": 4900, + "customer_details": {}, "metadata": {}, + }}, + } + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + resp = client.post("/api/billing/webhook", content=payload, + headers={"stripe-signature": header}) + assert resp.json()["action"] == "skipped:missing_email_or_tier" + + def test_webhook_tier_via_metadata(self, client): + evt = { + "type": "checkout.session.completed", + "data": {"object": { + "id": "cs_test_meta", "amount_total": 9999, + "customer_details": {"email": "meta@example.com"}, + "metadata": {"tier": "team"}, + }}, + } + payload = json.dumps(evt).encode("utf-8") + ts = int(time.time()) + header = _sign(payload, "whsec_test_secret", ts) + resp = client.post("/api/billing/webhook", content=payload, + headers={"stripe-signature": header}) + assert resp.json()["action"] == "license_created" + from src.billing import licenses + info = licenses.validate_license(resp.json()["license_key"]) + assert info.tier == "team" diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..ec3c2fa --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,271 @@ +""" +Tests for Pro-tier Excel + PDF export module. + +Coverage: +- Each xlsx builder returns non-empty bytes that openpyxl can re-open. +- Each pdf builder returns bytes starting with b"%PDF". +- End-to-end API test: POST /api/export/queuing/xlsx with X-License-Key: dev. +""" +from __future__ import annotations + +from io import BytesIO + +import pytest +from fastapi.testclient import TestClient +from openpyxl import load_workbook + +from main import app +from src.export.excel import build_inventory_xlsx, build_lp_xlsx, build_queuing_xlsx +from src.export.pdf import build_inventory_pdf, build_lp_pdf, build_queuing_pdf + +client = TestClient(app, raise_server_exceptions=True) + +# ── Sample data fixtures ─────────────────────────────────────────────────────── + +QUEUING_PARAMS = { + "model": "MM1", + "arrival_rate": 4.0, + "service_rate": 6.0, + "num_servers": 1, +} + +QUEUING_RESULT = { + "model": "MM1", + "utilization": 0.6667, + "L": 2.0, + "Lq": 1.3333, + "W": 0.5, + "Wq": 0.3333, + "P0": 0.3333, + "P_wait": 0.6667, + "prob_dist": [0.3333, 0.2222, 0.1481, 0.0988, 0.0658, + 0.0439, 0.0293, 0.0195, 0.013, 0.0087, + 0.0058, 0.0039, 0.0026, 0.0017, 0.0011, + 0.0008, 0.0005, 0.0003, 0.0002, 0.0002, 0.0001], + "little_law_check": 1e-10, + "blocking_prob": None, + "effective_lam": None, + "notes": None, +} + +INVENTORY_PARAMS = { + "annual_demand": 10000, + "ordering_cost": 200, + "unit_cost": 50, + "holding_rate": 0.25, +} + +INVENTORY_RESULT = { + "eoq": 400.0, + "orders_per_year": 25.0, + "cycle_time_days": 14.6, + "total_annual_cost": 5000.0, + "cost_curve_q": [100.0, 200.0, 400.0, 600.0, 800.0], + "cost_curve_tc": [5800.0, 5200.0, 5000.0, 5133.0, 5350.0], + "cost_curve_holding": [1250.0, 2500.0, 5000.0, 7500.0, 10000.0], + "cost_curve_ordering": [20000.0, 10000.0, 5000.0, 3333.0, 2500.0], +} + +LP_PARAMS = { + "objective": "maximize", + "c_obj": [5.0, 4.0], + "A_ub": [[6.0, 4.0], [1.0, 2.0]], + "b_ub": [24.0, 6.0], + "variable_names": ["x1", "x2"], + "constraint_names": ["c1", "c2"], +} + +LP_RESULT = { + "status": "optimal", + "optimal_value": 21.0, + "variables": {"x1": 3.0, "x2": 1.5}, + "shadow_prices": {"c1": 0.75, "c2": 0.25}, + "reduced_costs": {"x1": 0.0, "x2": 0.0}, + "binding_constraints": ["c1"], + "slacks": {"c1": 0.0, "c2": 2.0}, + "rhs_ranges": { + "c1": {"lower": 18.0, "current": 24.0, "upper": 30.0}, + "c2": {"lower": 3.0, "current": 6.0, "upper": 12.0}, + }, + "obj_ranges": { + "x1": {"lower": 4.0, "current": 5.0, "upper": 8.0}, + "x2": {"lower": 2.5, "current": 4.0, "upper": 5.0}, + }, +} + + +# ── Excel builder tests ──────────────────────────────────────────────────────── + +class TestQueuingXlsx: + def test_returns_nonempty_bytes(self): + data = build_queuing_xlsx(QUEUING_RESULT, QUEUING_PARAMS) + assert isinstance(data, bytes) + assert len(data) > 0 + + def test_openpyxl_can_reopen(self): + data = build_queuing_xlsx(QUEUING_RESULT, QUEUING_PARAMS) + wb = load_workbook(BytesIO(data)) + assert wb is not None + + def test_correct_sheet_names(self): + data = build_queuing_xlsx(QUEUING_RESULT, QUEUING_PARAMS) + wb = load_workbook(BytesIO(data)) + assert set(wb.sheetnames) == {"Summary", "Probability Distribution", "Inputs"} + + def test_summary_has_utilization(self): + data = build_queuing_xlsx(QUEUING_RESULT, QUEUING_PARAMS) + wb = load_workbook(BytesIO(data)) + ws = wb["Summary"] + labels = [ws.cell(row=r, column=1).value for r in range(2, ws.max_row + 1)] + assert any("Utilization" in str(lbl) for lbl in labels if lbl) + + def test_prob_dist_sheet_has_21_data_rows(self): + data = build_queuing_xlsx(QUEUING_RESULT, QUEUING_PARAMS) + wb = load_workbook(BytesIO(data)) + ws = wb["Probability Distribution"] + # Row 1 = header, rows 2-22 = data (21 rows) + data_rows = [r for r in range(2, ws.max_row + 1) if ws.cell(row=r, column=1).value is not None] + assert len(data_rows) == 21 + + +class TestInventoryXlsx: + def test_returns_nonempty_bytes(self): + data = build_inventory_xlsx(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + assert isinstance(data, bytes) + assert len(data) > 0 + + def test_openpyxl_can_reopen(self): + data = build_inventory_xlsx(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + wb = load_workbook(BytesIO(data)) + assert wb is not None + + def test_correct_sheet_names(self): + data = build_inventory_xlsx(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + wb = load_workbook(BytesIO(data)) + assert "Summary" in wb.sheetnames + assert "Cost Curve" in wb.sheetnames + assert "Inputs" in wb.sheetnames + + def test_no_list_fields_in_summary(self): + data = build_inventory_xlsx(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + wb = load_workbook(BytesIO(data)) + ws = wb["Summary"] + labels = [ws.cell(row=r, column=1).value for r in range(2, ws.max_row + 1)] + assert all("cost_curve" not in str(lbl) for lbl in labels if lbl) + + +class TestLPXlsx: + def test_returns_nonempty_bytes(self): + data = build_lp_xlsx(LP_RESULT, LP_PARAMS) + assert isinstance(data, bytes) + assert len(data) > 0 + + def test_openpyxl_can_reopen(self): + data = build_lp_xlsx(LP_RESULT, LP_PARAMS) + wb = load_workbook(BytesIO(data)) + assert wb is not None + + def test_correct_sheet_names(self): + data = build_lp_xlsx(LP_RESULT, LP_PARAMS) + wb = load_workbook(BytesIO(data)) + assert set(wb.sheetnames) == {"Solution", "Constraints", "Objective Ranging"} + + +# ── PDF builder tests ────────────────────────────────────────────────────────── + +class TestQueuingPdf: + def test_returns_bytes_starting_with_pdf_header(self): + data = build_queuing_pdf(QUEUING_RESULT, QUEUING_PARAMS) + assert isinstance(data, bytes) + assert data[:4] == b"%PDF" + + def test_nonempty(self): + data = build_queuing_pdf(QUEUING_RESULT, QUEUING_PARAMS) + assert len(data) > 1000 # PDFs are always larger than this + + +class TestInventoryPdf: + def test_returns_bytes_starting_with_pdf_header(self): + data = build_inventory_pdf(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + assert isinstance(data, bytes) + assert data[:4] == b"%PDF" + + def test_nonempty(self): + data = build_inventory_pdf(INVENTORY_RESULT, INVENTORY_PARAMS, "eoq") + assert len(data) > 1000 + + +class TestLPPdf: + def test_returns_bytes_starting_with_pdf_header(self): + data = build_lp_pdf(LP_RESULT, LP_PARAMS) + assert isinstance(data, bytes) + assert data[:4] == b"%PDF" + + def test_nonempty(self): + data = build_lp_pdf(LP_RESULT, LP_PARAMS) + assert len(data) > 1000 + + +# ── End-to-end API tests ─────────────────────────────────────────────────────── + +class TestExportAPI: + def test_queuing_xlsx_endpoint_returns_valid_xlsx(self): + r = client.post( + "/api/export/queuing/xlsx", + json={"params": QUEUING_PARAMS, "result": QUEUING_RESULT}, + headers={"X-License-Key": "dev"}, + ) + assert r.status_code == 200 + assert "spreadsheetml" in r.headers["content-type"] + assert "attachment" in r.headers["content-disposition"] + assert "sim2sim-queuing.xlsx" in r.headers["content-disposition"] + + # Verify openpyxl can reopen the response body + wb = load_workbook(BytesIO(r.content)) + assert "Summary" in wb.sheetnames + + def test_queuing_pdf_endpoint_returns_pdf(self): + r = client.post( + "/api/export/queuing/pdf", + json={"params": QUEUING_PARAMS, "result": QUEUING_RESULT}, + headers={"X-License-Key": "dev"}, + ) + assert r.status_code == 200 + assert r.headers["content-type"] == "application/pdf" + assert r.content[:4] == b"%PDF" + + def test_inventory_xlsx_endpoint_returns_valid_xlsx(self): + r = client.post( + "/api/export/inventory/xlsx", + json={ + "params": INVENTORY_PARAMS, + "result": INVENTORY_RESULT, + "model_kind": "eoq", + }, + headers={"X-License-Key": "dev"}, + ) + assert r.status_code == 200 + wb = load_workbook(BytesIO(r.content)) + assert "Summary" in wb.sheetnames + + def test_lp_xlsx_endpoint_returns_valid_xlsx(self): + r = client.post( + "/api/export/lp/xlsx", + json={"params": LP_PARAMS, "result": LP_RESULT}, + headers={"X-License-Key": "dev"}, + ) + assert r.status_code == 200 + wb = load_workbook(BytesIO(r.content)) + assert "Solution" in wb.sheetnames + + def test_invalid_model_kind_rejected(self): + r = client.post( + "/api/export/inventory/xlsx", + json={ + "params": INVENTORY_PARAMS, + "result": INVENTORY_RESULT, + "model_kind": "invalid_model", + }, + headers={"X-License-Key": "dev"}, + ) + assert r.status_code == 422 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index aadd835..8a255a0 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -3,7 +3,12 @@ """ import math import pytest -from src.models.inventory import solve_eoq, solve_newsvendor +from src.models.inventory import ( + solve_base_stock, + solve_eoq, + solve_newsvendor, + solve_reorder_point, +) class TestEOQ: @@ -117,3 +122,60 @@ def test_invalid_cost_exceeds_price(self): def test_invalid_salvage_exceeds_cost(self): with pytest.raises(ValueError, match="unit_cost must be greater than salvage_value"): solve_newsvendor(p=100, c=40, s=50, demand_mean=100, demand_std=20) + + +class TestReorderPoint: + """(Q, r) continuous-review inventory model.""" + + def test_basic_correctness(self): + """r = D_day · L_days + z · sigma_d · sqrt(L_days).""" + D, L, sigma_d = 10_000, 18.0, 10.0 + r = solve_reorder_point(D=D, L_days=L, sigma_d=sigma_d, + K=200, c=50, i=0.25, service_level=0.95) + D_day = D / 365.0 + expected_mu_L = D_day * L + expected_sigma = sigma_d * math.sqrt(L) + assert r.demand_lead_time == pytest.approx(expected_mu_L, rel=1e-4) + assert r.std_lead_time == pytest.approx(expected_sigma, rel=1e-4) + + def test_higher_service_level_means_more_safety_stock(self): + common = dict(D=10_000, L_days=18.0, sigma_d=10.0, K=200, c=50, i=0.25) + r_95 = solve_reorder_point(service_level=0.95, **common) + r_99 = solve_reorder_point(service_level=0.99, **common) + assert r_99.safety_stock > r_95.safety_stock + assert r_99.reorder_point > r_95.reorder_point + + def test_zero_variability_means_zero_safety_stock(self): + r = solve_reorder_point(D=10_000, L_days=18.0, sigma_d=0.0, + K=200, c=50, i=0.25, service_level=0.95) + assert r.safety_stock == pytest.approx(0.0, abs=1e-9) + + +class TestBaseStock: + """Base-stock (order-up-to) inventory model.""" + + def test_basic_correctness(self): + D, L, sigma_d = 10_000, 18.0, 10.0 + r = solve_base_stock(D=D, L_days=L, sigma_d=sigma_d, c=50, i=0.25, + service_level=0.95) + D_day = D / 365.0 + assert r.demand_lead_time == pytest.approx(D_day * L, rel=1e-4) + assert r.std_lead_time == pytest.approx(sigma_d * math.sqrt(L), rel=1e-4) + + def test_fill_rate_in_unit_interval(self): + """Type-II fill rate must always be in [0, 1].""" + r = solve_base_stock(D=10_000, L_days=18.0, sigma_d=10.0, c=50, i=0.25, + service_level=0.95) + assert 0.0 <= r.fill_rate <= 1.0 + + def test_fill_rate_increases_with_service_level(self): + common = dict(D=10_000, L_days=18.0, sigma_d=10.0, c=50, i=0.25) + r_90 = solve_base_stock(service_level=0.90, **common) + r_99 = solve_base_stock(service_level=0.99, **common) + assert r_99.fill_rate > r_90.fill_rate + + def test_no_variability_zero_backorders(self): + r = solve_base_stock(D=10_000, L_days=18.0, sigma_d=0.0, c=50, i=0.25, + service_level=0.95) + assert r.expected_backorders == pytest.approx(0.0, abs=1e-9) + assert r.fill_rate == pytest.approx(1.0, abs=1e-9)