From 81df512570d4bde71e3c2a55055ebcea7ab6969f Mon Sep 17 00:00:00 2001 From: Gavin Anderson Date: Tue, 12 May 2026 16:23:26 +0000 Subject: [PATCH 1/4] Phase 1 fixes: rename, CI, correctness, units, polish - Rename Sim2Real -> Sim2Sim across README, page, hero, docstrings, CSS, JS - Add GitHub Actions CI workflow (fixes broken badge) - Fix solve_base_stock dead code; correct Type-II fill-rate formula (1 - E[B per cycle] / mu_L, not E_B * D / D) - Standardize inventory lead-time to DAYS everywhere (lead_time_days param; sigma_L = sigma_d * sqrt(L_days)) for unit consistency - Add _little_law_residual: relative Little's Law diagnostic instead of absolute - Simulation: fix warm-up off-by-one; raise default warmup to max(100, N/5) - Production CORS: require ALLOWED_ORIGIN (fail loudly on empty) - Cache Anthropic client lazily at module level - Add pyproject.toml with ruff + mypy + pytest config - README polish: live demo placeholder, screenshot, monetization note - Add 7 new tests for reorder-point and base-stock (57 -> 64 passing) --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++ README.md | 8 +++-- main.py | 20 +++++++++---- pyproject.toml | 44 +++++++++++++++++++++++++++ src/ai/explainer.py | 26 +++++++++++----- src/api/routes.py | 6 ++-- src/api/schemas.py | 10 +++---- src/models/inventory.py | 64 ++++++++++++++++++++++++---------------- src/models/queuing.py | 27 +++++++++++------ src/models/simulation.py | 12 ++++---- static/css/style.css | 2 +- static/index.html | 10 +++---- static/js/api.js | 2 +- static/js/app.js | 4 +-- static/js/charts.js | 2 +- tests/test_inventory.py | 64 +++++++++++++++++++++++++++++++++++++++- 16 files changed, 265 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml 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/README.md b/README.md index c23c6fc..860b748 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ -# 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)* 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** upgrade for advanced features (scenario library, Excel/PDF export, batch API). + +![Sim2Sim Screenshot](docs/screenshot.png) --- diff --git a/main.py b/main.py index 35d8f62..dee29e6 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 @@ -27,7 +27,7 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="Sim2Real", + title="Sim2Sim", description="Operations Research Simulator with AI-powered explanations", version="1.0.0", docs_url="/api/docs", @@ -40,10 +40,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, 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/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..50ec92b 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -217,7 +217,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 +240,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: diff --git a/src/api/schemas.py b/src/api/schemas.py index 385fb45..5dc8372 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) 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/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..96db68f 100644 --- a/static/index.html +++ b/static/index.html @@ -3,8 +3,8 @@ - - Sim2Real | Operations Research Platform + + Sim2Sim | Operations Research Platform @@ -33,7 +33,7 @@
- Sim2Real + Sim2Sim Operations Research
diff --git a/static/js/app.js b/static/js/app.js index f509da5..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 }); @@ -475,6 +495,10 @@ 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); } @@ -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/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" From c512edb56bda83b821d262ec3368c631cefb2699 Mon Sep 17 00:00:00 2001 From: Gavin Anderson Date: Tue, 12 May 2026 16:50:26 +0000 Subject: [PATCH 4/4] README: link to LAUNCH.md from header --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b77adbf..3caca2f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ [![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:** *(coming soon — deployment in progress)* +**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.