From 2748c606e9cf623dfbe8ba38391fb1883fcbaaa4 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 18 Feb 2026 16:11:50 +0100 Subject: [PATCH] Add BeanFlows MVP: coffee analytics dashboard, API, and web app - Fix pipeline granularity: add market_year to cleaned/serving SQL models - Add DuckDB data access layer with async query functions (analytics.py) - Build Chart.js dashboard: supply/demand, STU ratio, top producers, YoY table - Add country comparison page with multi-select picker - Replace items CRUD with read-only commodity API (list, metrics, countries, CSV) - Configure BeanFlows plan tiers (Free/Starter/Pro) with feature gating - Rewrite public pages for coffee market intelligence positioning - Remove boilerplate items schema, update health check for DuckDB - Add test suite: 139 tests passing (dashboard, API, billing) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 32 ++ .../cln_psdalldata__commodity_pivoted.sql | 3 + .../models/serving/obt_commodity_metrics.sql | 10 +- uv.lock | 194 ++++++++++ web/.copier-answers.yml | 10 + web/.dockerignore | 17 + web/.env.example | 30 ++ web/.gitignore | 37 ++ web/.python-version | 1 + web/CLAUDE.md | 78 ++++ web/Dockerfile | 22 ++ web/README.md | 0 web/deploy.sh | 76 ++++ web/docker-compose.prod.yml | 128 +++++++ web/docker-compose.yml | 56 +++ web/litestream.yml | 22 ++ web/pyproject.toml | 48 +++ web/router/default.conf | 15 + web/scripts/backup.sh | 23 ++ web/scripts/deploy.sh | 22 ++ web/src/beanflows/__init__.py | 3 + web/src/beanflows/admin/routes.py | 340 ++++++++++++++++++ web/src/beanflows/admin/templates/index.html | 124 +++++++ web/src/beanflows/admin/templates/login.html | 30 ++ web/src/beanflows/admin/templates/tasks.html | 106 ++++++ .../admin/templates/user_detail.html | 73 ++++ web/src/beanflows/admin/templates/users.html | 83 +++++ web/src/beanflows/analytics.py | 220 ++++++++++++ web/src/beanflows/api/routes.py | 191 ++++++++++ web/src/beanflows/app.py | 123 +++++++ web/src/beanflows/auth/routes.py | 314 ++++++++++++++++ web/src/beanflows/auth/templates/login.html | 39 ++ .../auth/templates/magic_link_sent.html | 35 ++ web/src/beanflows/auth/templates/signup.html | 44 +++ web/src/beanflows/billing/routes.py | 275 ++++++++++++++ .../beanflows/billing/templates/pricing.html | 120 +++++++ .../beanflows/billing/templates/success.html | 26 ++ web/src/beanflows/core.py | 334 +++++++++++++++++ web/src/beanflows/dashboard/routes.py | 246 +++++++++++++ .../dashboard/templates/countries.html | 101 ++++++ .../beanflows/dashboard/templates/index.html | 211 +++++++++++ .../dashboard/templates/settings.html | 155 ++++++++ web/src/beanflows/migrations/migrate.py | 53 +++ web/src/beanflows/migrations/schema.sql | 101 ++++++ web/src/beanflows/public/routes.py | 45 +++ web/src/beanflows/public/templates/about.html | 34 ++ .../beanflows/public/templates/features.html | 73 ++++ .../beanflows/public/templates/landing.html | 91 +++++ .../beanflows/public/templates/privacy.html | 92 +++++ web/src/beanflows/public/templates/terms.html | 71 ++++ web/src/beanflows/static/css/custom.css | 40 +++ web/src/beanflows/templates/base.html | 97 +++++ web/src/beanflows/worker.py | 238 ++++++++++++ web/tests/conftest.py | 247 +++++++++++++ web/tests/test_api_commodities.py | 129 +++++++ web/tests/test_billing_helpers.py | 325 +++++++++++++++++ web/tests/test_billing_routes.py | 268 ++++++++++++++ web/tests/test_billing_webhooks.py | 274 ++++++++++++++ web/tests/test_dashboard.py | 79 ++++ 59 files changed, 6272 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 web/.copier-answers.yml create mode 100644 web/.dockerignore create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/.python-version create mode 100644 web/CLAUDE.md create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/deploy.sh create mode 100644 web/docker-compose.prod.yml create mode 100644 web/docker-compose.yml create mode 100644 web/litestream.yml create mode 100644 web/pyproject.toml create mode 100644 web/router/default.conf create mode 100644 web/scripts/backup.sh create mode 100644 web/scripts/deploy.sh create mode 100644 web/src/beanflows/__init__.py create mode 100644 web/src/beanflows/admin/routes.py create mode 100644 web/src/beanflows/admin/templates/index.html create mode 100644 web/src/beanflows/admin/templates/login.html create mode 100644 web/src/beanflows/admin/templates/tasks.html create mode 100644 web/src/beanflows/admin/templates/user_detail.html create mode 100644 web/src/beanflows/admin/templates/users.html create mode 100644 web/src/beanflows/analytics.py create mode 100644 web/src/beanflows/api/routes.py create mode 100644 web/src/beanflows/app.py create mode 100644 web/src/beanflows/auth/routes.py create mode 100644 web/src/beanflows/auth/templates/login.html create mode 100644 web/src/beanflows/auth/templates/magic_link_sent.html create mode 100644 web/src/beanflows/auth/templates/signup.html create mode 100644 web/src/beanflows/billing/routes.py create mode 100644 web/src/beanflows/billing/templates/pricing.html create mode 100644 web/src/beanflows/billing/templates/success.html create mode 100644 web/src/beanflows/core.py create mode 100644 web/src/beanflows/dashboard/routes.py create mode 100644 web/src/beanflows/dashboard/templates/countries.html create mode 100644 web/src/beanflows/dashboard/templates/index.html create mode 100644 web/src/beanflows/dashboard/templates/settings.html create mode 100644 web/src/beanflows/migrations/migrate.py create mode 100644 web/src/beanflows/migrations/schema.sql create mode 100644 web/src/beanflows/public/routes.py create mode 100644 web/src/beanflows/public/templates/about.html create mode 100644 web/src/beanflows/public/templates/features.html create mode 100644 web/src/beanflows/public/templates/landing.html create mode 100644 web/src/beanflows/public/templates/privacy.html create mode 100644 web/src/beanflows/public/templates/terms.html create mode 100644 web/src/beanflows/static/css/custom.css create mode 100644 web/src/beanflows/templates/base.html create mode 100644 web/src/beanflows/worker.py create mode 100644 web/tests/conftest.py create mode 100644 web/tests/test_api_commodities.py create mode 100644 web/tests/test_billing_helpers.py create mode 100644 web/tests/test_billing_routes.py create mode 100644 web/tests/test_billing_webhooks.py create mode 100644 web/tests/test_dashboard.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..882fc62 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to BeanFlows are documented here. + +## [Unreleased] + +### Added +- **Coffee analytics dashboard** — Chart.js visualizations for global supply/demand time series, stock-to-use ratio trend, top producing countries bar chart, and YoY production change table +- **Country comparison page** — Multi-select country picker with metric selector and overlay line chart at `/dashboard/countries` +- **DuckDB data access layer** (`web/src/beanflows/analytics.py`) — Async bridge to read-only DuckDB via `asyncio.to_thread()` with 7 domain query functions and metric allowlist for injection prevention +- **Commodity REST API** — `GET /api/v1/commodities`, `/commodities//metrics`, `/commodities//countries`, `/commodities//metrics.csv` (CSV export) +- **BeanFlows plan tiers** — Free (coffee, 5yr history), Starter (full history, CSV, API), Pro (all 65 commodities, unlimited API) +- **Landing, features, and pricing pages** rewritten for coffee market intelligence positioning +- **Health check** now verifies both SQLite and DuckDB connectivity +- **Admin panel** shows commodity count and data year range from DuckDB +- **Docker** config updated with DuckDB volume mount +- **Tests** — `test_dashboard.py` (8 tests), `test_api_commodities.py` (8 tests), analytics mock fixture in conftest + +### Fixed +- **Pipeline time granularity** — Added `market_year` to GROUP BY in `cleaned.psdalldata__commodity_pivoted` and carried through to `serving.commodity_metrics`. Previously summed across all market years, making per-year metrics meaningless. + +### Removed +- `items` table, FTS virtual table, and triggers from schema (boilerplate domain entity) +- Items CRUD from API routes +- Items count from dashboard stats + +### Changed +- `PLAN_FEATURES` updated from generic (`basic`, `export`) to domain-specific (`dashboard`, `coffee_only`, `all_commodities`, `full_history`, `export`, `api`) +- `PLAN_LIMITS` changed from `items`/`api_calls` to `commodities`/`history_years`/`api_calls` +- API now requires Starter or Pro plan (free plan gets 403) +- Dashboard routes and API routes import analytics module (not individual functions) for testability +- Billing tests updated to match new feature and limit names diff --git a/transform/sqlmesh_materia/models/cleaned/cln_psdalldata__commodity_pivoted.sql b/transform/sqlmesh_materia/models/cleaned/cln_psdalldata__commodity_pivoted.sql index cbe6bdd..029e459 100644 --- a/transform/sqlmesh_materia/models/cleaned/cln_psdalldata__commodity_pivoted.sql +++ b/transform/sqlmesh_materia/models/cleaned/cln_psdalldata__commodity_pivoted.sql @@ -13,6 +13,7 @@ SELECT max(commodity_name) as commodity_name, country_code, max(country_name) as country_name, + market_year, ingest_date, COALESCE(SUM(CASE WHEN attribute_name = 'Production' THEN value END), 0) AS Production, COALESCE(SUM(CASE WHEN attribute_name = 'Imports' THEN value END), 0) AS Imports, @@ -48,8 +49,10 @@ WHERE attribute_name IN ( GROUP BY commodity_code, country_code, + market_year, ingest_date ORDER BY commodity_code, country_code, + market_year, ingest_date diff --git a/transform/sqlmesh_materia/models/serving/obt_commodity_metrics.sql b/transform/sqlmesh_materia/models/serving/obt_commodity_metrics.sql index 943c5c5..59b3093 100644 --- a/transform/sqlmesh_materia/models/serving/obt_commodity_metrics.sql +++ b/transform/sqlmesh_materia/models/serving/obt_commodity_metrics.sql @@ -14,6 +14,7 @@ WITH country_metrics AS ( commodity_name, country_code, country_name, + market_year, ingest_date, Production, Imports, @@ -27,7 +28,7 @@ WITH country_metrics AS ( -- Handle division by zero for Stock-to-Use Ratio (Ending_Stocks / NULLIF(Total_Distribution, 0)) * 100 AS Stock_to_Use_Ratio_pct, -- Calculate Production YoY percentage change using a window function - (Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY ingest_date), 0) * 100 AS Production_YoY_pct + (Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY market_year, ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code, country_code ORDER BY market_year, ingest_date), 0) * 100 AS Production_YoY_pct FROM cleaned.psdalldata__commodity_pivoted ), global_aggregates AS ( @@ -36,6 +37,7 @@ global_aggregates AS ( commodity_name, NULL::TEXT AS country_code, -- Use NULL for global aggregates 'Global' AS country_name, + market_year, ingest_date, SUM(Production) AS Production, SUM(Imports) AS Imports, @@ -46,6 +48,7 @@ global_aggregates AS ( GROUP BY commodity_code, commodity_name, + market_year, ingest_date ), -- CTE to calculate derived metrics for global aggregates @@ -55,6 +58,7 @@ global_metrics AS ( commodity_name, country_code, country_name, + market_year, ingest_date, Production, Imports, @@ -65,7 +69,7 @@ global_metrics AS ( (Exports - Imports) AS Trade_Balance, (Production + Imports - Exports) - Total_Distribution AS Supply_Demand_Balance, (Ending_Stocks / NULLIF(Total_Distribution, 0)) * 100 AS Stock_to_Use_Ratio_pct, - (Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY ingest_date), 0) * 100 AS Production_YoY_pct + (Production - LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY market_year, ingest_date)) / NULLIF(LAG(Production, 1, 0) OVER (PARTITION BY commodity_code ORDER BY market_year, ingest_date), 0) * 100 AS Production_YoY_pct FROM global_aggregates ) -- Combine country-level and global-level data into a single output @@ -74,6 +78,7 @@ SELECT commodity_name, country_code, country_name, + market_year, ingest_date, Production, Imports, @@ -97,4 +102,5 @@ FROM ( ORDER BY commodity_name, country_name, + market_year, ingest_date; diff --git a/uv.lock b/uv.lock index 49b292f..a21d473 100644 --- a/uv.lock +++ b/uv.lock @@ -9,11 +9,21 @@ resolution-markers = [ [manifest] members = [ + "beanflows", "materia", "psdonline", "sqlmesh-materia", ] +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + [[package]] name = "aiosqlite" version = "0.22.1" @@ -232,6 +242,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "beanflows" +version = "0.1.0" +source = { editable = "web" } +dependencies = [ + { name = "aiosqlite" }, + { name = "duckdb" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "python-dotenv" }, + { name = "quart" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.19.0" }, + { name = "duckdb", specifier = ">=1.0.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypercorn", specifier = ">=0.17.0" }, + { name = "itsdangerous", specifier = ">=2.1.0" }, + { name = "jinja2", specifier = ">=3.1.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "quart", specifier = ">=0.19.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.100.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "respx", specifier = ">=0.22.0" }, + { name = "ruff", specifier = ">=0.3.0" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -241,6 +296,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "boto3" version = "1.40.55" @@ -706,6 +770,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + [[package]] name = "fsspec" version = "2026.1.0" @@ -902,6 +983,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, ] +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + [[package]] name = "hyperframe" version = "6.1.0" @@ -920,6 +1016,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/2d/05a668e7dd7eaf0d7ee9bc162ebf358e9be9af43e4720cdecf622ccc6c9b/hyperscript-0.3.0-py3-none-any.whl", hash = "sha256:5b1689c5cd4ab7c076a0bc61bff11200aa2e0e788bb50358be496e77924df4f0", size = 4970, upload-time = "2024-10-22T17:17:22.807Z" }, ] +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + [[package]] name = "identify" version = "2.6.13" @@ -1041,6 +1149,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/6a/9166369a2f092bd286d24e6307de555d63616e8ddb373ebad2b5635ca4cd/ipywidgets-8.1.7-py3-none-any.whl", hash = "sha256:764f2602d25471c213919b8a1997df04bef869251db4ca8efba1b76b1bd9f7bb", size = 139806, upload-time = "2025-05-05T12:41:56.833Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -1801,6 +1918,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/95/2145b372daecb8d449530afb12e3e2cb3cd9aceeeecae8ba001dc1bb5ed2/prefect-3.6.15-py3-none-any.whl", hash = "sha256:eff2b5a1385677b9d94629b94428ddddbb9df63eadd8a870f05ef1ab124f9b84", size = 11799648, upload-time = "2026-01-30T00:49:57.145Z" }, ] +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + [[package]] name = "prometheus-client" version = "0.24.1" @@ -2163,6 +2289,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -2338,6 +2476,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/e4/9159114a1d96c0442e1465ace2ec1f197e5027db6f794887cf2ca386cc40/qh3-1.5.4-cp37-abi3-win_amd64.whl", hash = "sha256:90ce786909cd7d39db158d86d4c9569d2aebfb18782d04c81b98a1b912489b5a", size = 1991452, upload-time = "2025-08-11T06:47:58.663Z" }, ] +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, +] + [[package]] name = "readchar" version = "4.2.1" @@ -2433,6 +2591,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -3137,6 +3307,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + [[package]] name = "whenever" version = "0.9.5" @@ -3215,6 +3397,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "zipp" version = "3.23.0" diff --git a/web/.copier-answers.yml b/web/.copier-answers.yml new file mode 100644 index 0000000..bee3b70 --- /dev/null +++ b/web/.copier-answers.yml @@ -0,0 +1,10 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: v0.3.0 +_src_path: /home/Deeman/Projects/quart_saas_boilerplate +author_email: hendrik@beanflows.coffee +author_name: Hendrik Deeman +base_url: https://beanflows.coffee +description: Commodity analytics for coffee traders +payment_provider: paddle +project_name: BeanFlows +project_slug: beanflows diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..31cc5f8 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,17 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.git/ +.gitignore +.env +.env.* +*.db +*.db-shm +*.db-wal +data/ +backups/ +.ruff_cache/ +.pytest_cache/ +.vscode/ +.idea/ diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..30940aa --- /dev/null +++ b/web/.env.example @@ -0,0 +1,30 @@ +# App +APP_NAME=BeanFlows +SECRET_KEY=change-me-generate-a-real-secret +BASE_URL=http://localhost:5000 +DEBUG=true +ADMIN_PASSWORD=admin + +# Database +DATABASE_PATH=data/app.db +DUCKDB_PATH=../local.duckdb + +# Auth +MAGIC_LINK_EXPIRY_MINUTES=15 +SESSION_LIFETIME_DAYS=30 + +# Email (Resend) +RESEND_API_KEY= +EMAIL_FROM=hello@example.com + + +# Paddle +PADDLE_API_KEY= +PADDLE_WEBHOOK_SECRET= +PADDLE_PRICE_STARTER= +PADDLE_PRICE_PRO= + + +# Rate limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=60 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..cea4fff --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.uv/ + +# Environment +.env +.env.local + +# Database +*.db +*.db-shm +*.db-wal +data/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Build +dist/ +build/ +*.egg-info/ diff --git a/web/.python-version b/web/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/web/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000..8285831 --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +A **Copier template** for generating SaaS applications. The template lives in `{{project_slug}}/` with Jinja2-templated files (`.jinja` extension). Files without `.jinja` are copied as-is. The `copier.yml` defines template variables: `project_slug`, `project_name`, `description`, `author_name`, `author_email`, `base_url`, `payment_provider` (stripe/paddle/lemonsqueezy). + +This is NOT a runnable application itself — it generates one via `copier copy`. + +## Generated Project Commands + +After generation, the project uses **uv** as package manager: + +```bash +uv sync # Install dependencies +uv run python -m .migrations.migrate # Initialize/migrate DB +uv run python -m .app # Run dev server (port 5000) +uv run python -m .worker # Run background worker +uv run python -m .worker scheduler # Run periodic scheduler +uv run pytest # Run tests +uv run ruff check . # Lint +docker compose up -d # Production deploy +``` + +## Stack + +- **Quart** (async Flask) with Jinja2 templates and Pico CSS (no build step) +- **SQLite** with WAL mode + aiosqlite (no ORM — plain SQL everywhere) +- **Stripe**, **Paddle**, or **LemonSqueezy** for billing (chosen via `payment_provider` template variable) +- **Resend** for transactional email +- **Litestream** for SQLite replication/backups +- **Docker + Caddy** for deployment +- **Hypercorn** as ASGI server in production + +## Architecture + +### Domain-based flat structure + +Each domain is a directory with `routes.py` + `templates/`. Routes, SQL queries, and decorators all live together in `routes.py` — no separate models/services/repositories layers. + +``` +src// + app.py → Application factory, blueprint registration, middleware + core.py → Config class, DB helpers (fetch_one/fetch_all/execute), email, CSRF, rate limiting + worker.py → SQLite-based background task queue (no Redis) + auth/ → Magic link auth, login_required/subscription_required decorators + billing/ → Checkout/webhooks/portal (Stripe, Paddle, or LemonSqueezy), plan feature/limit checks + dashboard/ → User settings, API key management + public/ → Marketing pages (landing, terms, privacy) + api/ → REST API with Bearer token auth (api_key_required decorator) + admin/ → Password-protected admin panel (ADMIN_PASSWORD env var) + migrations/ → schema.sql + migrate.py (runs full schema idempotently with CREATE IF NOT EXISTS) +``` + +### Key patterns + +- **Database access**: Use `fetch_one()`, `fetch_all()`, `execute()` from `core.py`. No ORM. Queries live directly in route files next to the routes that use them. +- **Auth decorators**: `@login_required` for user pages, `@subscription_required(plans=["pro"])` for plan-gated features, `@api_key_required(scopes=["read"])` for API endpoints. +- **CSRF**: `@csrf_protect` decorator on POST routes. Templates include ``. +- **Background tasks**: `from ..worker import enqueue` then `await enqueue("task_name", {"key": "value"})`. Register handlers with `@task("task_name")` decorator in `worker.py`. +- **Blueprints**: Each domain registers as a Quart Blueprint with its own `template_folder`. Templates in domain dirs override shared ones. +- **Soft deletes**: `deleted_at` column pattern. Use `soft_delete()`, `restore()`, `hard_delete()` from `core.py`. +- **Dates**: All stored as ISO 8601 TEXT in SQLite, using `datetime.utcnow().isoformat()`. +- **Rate limiting**: SQLite-based, no Redis. `@rate_limit()` decorator or `check_rate_limit()` function. +- **Migrations**: Single `schema.sql` file with all `CREATE TABLE IF NOT EXISTS`. Run via `python -m .migrations.migrate`. + +### Conditional template blocks + +`billing/routes.py.jinja` conditionally generates the full billing implementation based on the `payment_provider` variable (stripe/paddle/lemonsqueezy). The `.env.example.jinja`, `core.py.jinja`, and `schema.sql.jinja` similarly adapt per provider. + +## Design Philosophy + +Data-oriented, minimal abstraction. Plain SQL over ORM. Write code first, extract patterns only when repeated 3+ times. SQLite as the default database. Server-rendered HTML with Pico CSS. + +## Ruff Config + +Line length 100, target Python 3.11+, rules: E, F, I, UP (ignoring E501). diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..726d8f6 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +# Build stage +FROM python:3.12-slim AS build +COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/ +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy +COPY uv.lock pyproject.toml README.md ./ +COPY src/ ./src/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --frozen + +# Runtime stage +FROM python:3.12-slim AS runtime +ENV PATH="/app/.venv/bin:$PATH" +RUN useradd -m -u 1000 appuser +WORKDIR /app +RUN mkdir -p /app/data && chown -R appuser:appuser /app +COPY --from=build --chown=appuser:appuser /app . +USER appuser +ENV PYTHONUNBUFFERED=1 +ENV DATABASE_PATH=/app/data/app.db +EXPOSE 5000 +CMD ["hypercorn", "beanflows.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..e69de29 diff --git a/web/deploy.sh b/web/deploy.sh new file mode 100644 index 0000000..153c709 --- /dev/null +++ b/web/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE="docker compose -f docker-compose.prod.yml" +LIVE_FILE=".live-slot" +ROUTER_CONF="router/default.conf" + +# ── Determine slots ───────────────────────────────────────── + +CURRENT=$(cat "$LIVE_FILE" 2>/dev/null || echo "none") + +if [ "$CURRENT" = "blue" ]; then + TARGET="green" +else + TARGET="blue" +fi + +echo "==> Current: $CURRENT → Deploying: $TARGET" + +# ── Build ─────────────────────────────────────────────────── + +echo "==> Building $TARGET..." +$COMPOSE --profile "$TARGET" build + +# ── Migrate ───────────────────────────────────────────────── + +echo "==> Running migrations..." +$COMPOSE --profile "$TARGET" run --rm "${TARGET}-app" \ + python -m beanflows.migrations.migrate + +# ── Start & health check ─────────────────────────────────── + +echo "==> Starting $TARGET (waiting for health check)..." +if ! $COMPOSE --profile "$TARGET" up -d --wait; then + echo "!!! Health check failed — rolling back" + $COMPOSE stop "${TARGET}-app" "${TARGET}-worker" "${TARGET}-scheduler" + exit 1 +fi + +# ── Switch router ─────────────────────────────────────────── + +echo "==> Switching router to $TARGET..." +mkdir -p "$(dirname "$ROUTER_CONF")" +cat > "$ROUTER_CONF" < Stopping $CURRENT..." + $COMPOSE stop "${CURRENT}-app" "${CURRENT}-worker" "${CURRENT}-scheduler" +fi + +# ── Record live slot ──────────────────────────────────────── + +echo "$TARGET" > "$LIVE_FILE" +echo "==> Deployed $TARGET successfully!" diff --git a/web/docker-compose.prod.yml b/web/docker-compose.prod.yml new file mode 100644 index 0000000..d572da9 --- /dev/null +++ b/web/docker-compose.prod.yml @@ -0,0 +1,128 @@ +services: + # ── Always-on infrastructure ────────────────────────────── + + router: + image: nginx:alpine + restart: unless-stopped + ports: + - "5000:80" + volumes: + - ./router/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - net + healthcheck: + test: ["CMD", "nginx", "-t"] + interval: 30s + timeout: 5s + + litestream: + image: litestream/litestream:latest + restart: unless-stopped + command: replicate -config /etc/litestream.yml + volumes: + - app-data:/app/data + - ./beanflows/litestream.yml:/etc/litestream.yml:ro + + # ── Blue slot ───────────────────────────────────────────── + + blue-app: + profiles: ["blue"] + build: + context: ./beanflows + restart: unless-stopped + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + blue-worker: + profiles: ["blue"] + build: + context: ./beanflows + restart: unless-stopped + command: python -m beanflows.worker + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + blue-scheduler: + profiles: ["blue"] + build: + context: ./beanflows + restart: unless-stopped + command: python -m beanflows.worker scheduler + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + # ── Green slot ──────────────────────────────────────────── + + green-app: + profiles: ["green"] + build: + context: ./beanflows + restart: unless-stopped + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + green-worker: + profiles: ["green"] + build: + context: ./beanflows + restart: unless-stopped + command: python -m beanflows.worker + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + green-scheduler: + profiles: ["green"] + build: + context: ./beanflows + restart: unless-stopped + command: python -m beanflows.worker scheduler + env_file: ./beanflows/.env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + +volumes: + app-data: + +networks: + net: diff --git a/web/docker-compose.yml b/web/docker-compose.yml new file mode 100644 index 0000000..a29a6d4 --- /dev/null +++ b/web/docker-compose.yml @@ -0,0 +1,56 @@ +services: + app: + build: . + restart: unless-stopped + ports: + - "5000:5000" + volumes: + - ./data:/app/data + - ./duckdb:/app/duckdb:ro + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + - DUCKDB_PATH=/app/duckdb/local.duckdb + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + worker: + build: . + restart: unless-stopped + command: python -m beanflows.worker + volumes: + - ./data:/app/data + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + depends_on: + - app + + scheduler: + build: . + restart: unless-stopped + command: python -m beanflows.worker scheduler + volumes: + - ./data:/app/data + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + depends_on: + - app + + # Optional: Litestream for backups + litestream: + image: litestream/litestream:latest + restart: unless-stopped + command: replicate -config /etc/litestream.yml + volumes: + - ./data:/app/data + - ./litestream.yml:/etc/litestream.yml:ro + depends_on: + - app + +volumes: diff --git a/web/litestream.yml b/web/litestream.yml new file mode 100644 index 0000000..7fd27e6 --- /dev/null +++ b/web/litestream.yml @@ -0,0 +1,22 @@ +# Litestream configuration for SQLite replication +# Supports S3, Cloudflare R2, MinIO, etc. + +dbs: + - path: /app/data/app.db + replicas: + # Option 1: AWS S3 + # - url: s3://your-bucket/beanflows/app.db + # access-key-id: ${AWS_ACCESS_KEY_ID} + # secret-access-key: ${AWS_SECRET_ACCESS_KEY} + # region: us-east-1 + + # Option 2: Cloudflare R2 + # - url: s3://your-bucket/beanflows/app.db + # access-key-id: ${R2_ACCESS_KEY_ID} + # secret-access-key: ${R2_SECRET_ACCESS_KEY} + # endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com + + # Option 3: Local file backup (for development) + - path: /app/data/backups + retention: 24h + snapshot-interval: 1h diff --git a/web/pyproject.toml b/web/pyproject.toml new file mode 100644 index 0000000..b5d3eea --- /dev/null +++ b/web/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "beanflows" +version = "0.1.0" +description = "Commodity analytics for coffee traders" +readme = "README.md" +requires-python = ">=3.11" +authors = [ + { name = "Hendrik Deeman", email = "hendrik@beanflows.coffee" } +] +dependencies = [ + "quart>=0.19.0", + "aiosqlite>=0.19.0", + "duckdb>=1.0.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + + "itsdangerous>=2.1.0", + "jinja2>=3.1.0", + "hypercorn>=0.17.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/beanflows"] + +[tool.uv] +dev-dependencies = [ + "hypothesis>=6.100.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.22.0", + "ruff>=0.3.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/web/router/default.conf b/web/router/default.conf new file mode 100644 index 0000000..cb78a15 --- /dev/null +++ b/web/router/default.conf @@ -0,0 +1,15 @@ +upstream app { + server blue-app:5000; +} + +server { + listen 80; + + location / { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/web/scripts/backup.sh b/web/scripts/backup.sh new file mode 100644 index 0000000..ba6c74a --- /dev/null +++ b/web/scripts/backup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +# BeanFlows Manual Backup Script + +BACKUP_DIR="./backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +DB_PATH="./data/app.db" + +mkdir -p "$BACKUP_DIR" + +# Create backup using SQLite's backup command +sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/app_$TIMESTAMP.db'" + +# Compress +gzip "$BACKUP_DIR/app_$TIMESTAMP.db" + +echo "✅ Backup created: $BACKUP_DIR/app_$TIMESTAMP.db.gz" + +# Clean old backups (keep last 7 days) +find "$BACKUP_DIR" -name "*.db.gz" -mtime +7 -delete + +echo "🧹 Old backups cleaned" diff --git a/web/scripts/deploy.sh b/web/scripts/deploy.sh new file mode 100644 index 0000000..cdce459 --- /dev/null +++ b/web/scripts/deploy.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +# BeanFlows Deployment Script + +echo "🚀 Deploying BeanFlows..." + +# Pull latest code +git pull origin main + +# Build and restart containers +docker compose build +docker compose up -d + +# Run migrations +docker compose exec app uv run python -m beanflows.migrations.migrate + +# Health check +sleep 5 +curl -f http://localhost:5000/health || exit 1 + +echo "✅ Deployment complete!" diff --git a/web/src/beanflows/__init__.py b/web/src/beanflows/__init__.py new file mode 100644 index 0000000..03b5325 --- /dev/null +++ b/web/src/beanflows/__init__.py @@ -0,0 +1,3 @@ +"""BeanFlows - Commodity analytics for coffee traders""" + +__version__ = "0.1.0" diff --git a/web/src/beanflows/admin/routes.py b/web/src/beanflows/admin/routes.py new file mode 100644 index 0000000..1e9f0db --- /dev/null +++ b/web/src/beanflows/admin/routes.py @@ -0,0 +1,340 @@ +""" +Admin domain: password-protected admin panel for managing users, tasks, etc. +""" +import secrets +from datetime import datetime, timedelta +from functools import wraps +from pathlib import Path + +from quart import Blueprint, flash, redirect, render_template, request, session, url_for + +from ..core import config, csrf_protect, execute, fetch_all, fetch_one + +# Blueprint with its own template folder +bp = Blueprint( + "admin", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/admin", +) + + +# ============================================================================= +# Config +# ============================================================================= + +def get_admin_password() -> str: + """Get admin password from env. Generate one if not set (dev only).""" + import os + password = os.getenv("ADMIN_PASSWORD", "") + if not password and config.DEBUG: + # In dev, use a default password + return "admin" + return password + + +# ============================================================================= +# SQL Queries +# ============================================================================= + +async def get_dashboard_stats() -> dict: + """Get admin dashboard statistics.""" + now = datetime.utcnow() + today = now.date().isoformat() + week_ago = (now - timedelta(days=7)).isoformat() + + users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL") + users_today = await fetch_one( + "SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL", + (today,), + ) + users_week = await fetch_one( + "SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL", + (week_ago,), + ) + + subs = await fetch_one( + "SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active'" + ) + + tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'") + tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'") + + # Analytics stats (DuckDB) + analytics = {"commodity_count": 0, "min_year": None, "max_year": None} + try: + from ..analytics import _conn as duckdb_conn + from ..analytics import fetch_analytics + + if duckdb_conn is not None: + rows = await fetch_analytics( + """ + SELECT COUNT(DISTINCT commodity_code) as commodity_count, + MIN(market_year) as min_year, + MAX(market_year) as max_year + FROM serving.commodity_metrics + WHERE country_code IS NOT NULL + """ + ) + if rows: + analytics = rows[0] + except Exception: + pass + + return { + "users_total": users_total["count"] if users_total else 0, + "users_today": users_today["count"] if users_today else 0, + "users_week": users_week["count"] if users_week else 0, + "active_subscriptions": subs["count"] if subs else 0, + "tasks_pending": tasks_pending["count"] if tasks_pending else 0, + "tasks_failed": tasks_failed["count"] if tasks_failed else 0, + "commodity_count": analytics.get("commodity_count", 0), + "data_year_range": f"{analytics.get('min_year', '?')}–{analytics.get('max_year', '?')}", + } + + +async def get_users(limit: int = 50, offset: int = 0, search: str = None) -> list[dict]: + """Get users with optional search.""" + if search: + return await fetch_all( + """ + SELECT u.*, s.plan, s.status as sub_status + FROM users u + LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active' + WHERE u.deleted_at IS NULL AND u.email LIKE ? + ORDER BY u.created_at DESC + LIMIT ? OFFSET ? + """, + (f"%{search}%", limit, offset) + ) + return await fetch_all( + """ + SELECT u.*, s.plan, s.status as sub_status + FROM users u + LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active' + WHERE u.deleted_at IS NULL + ORDER BY u.created_at DESC + LIMIT ? OFFSET ? + """, + (limit, offset) + ) + + +async def get_user_by_id(user_id: int) -> dict | None: + """Get user by ID with subscription info.""" + return await fetch_one( + """ + SELECT u.*, s.plan, s.status as sub_status, s.stripe_customer_id + FROM users u + LEFT JOIN subscriptions s ON s.user_id = u.id + WHERE u.id = ? + """, + (user_id,) + ) + + +async def get_recent_tasks(limit: int = 50) -> list[dict]: + """Get recent tasks.""" + return await fetch_all( + """ + SELECT * FROM tasks + ORDER BY created_at DESC + LIMIT ? + """, + (limit,) + ) + + +async def get_failed_tasks() -> list[dict]: + """Get failed tasks.""" + return await fetch_all( + "SELECT * FROM tasks WHERE status = 'failed' ORDER BY created_at DESC" + ) + + +async def retry_task(task_id: int) -> bool: + """Retry a failed task.""" + result = await execute( + """ + UPDATE tasks + SET status = 'pending', run_at = ?, error = NULL + WHERE id = ? AND status = 'failed' + """, + (datetime.utcnow().isoformat(), task_id) + ) + return result > 0 + + +async def delete_task(task_id: int) -> bool: + """Delete a task.""" + result = await execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + return result > 0 + + +# ============================================================================= +# Decorators +# ============================================================================= + +def admin_required(f): + """Require admin authentication.""" + @wraps(f) + async def decorated(*args, **kwargs): + if not session.get("is_admin"): + return redirect(url_for("admin.login")) + return await f(*args, **kwargs) + return decorated + + +# ============================================================================= +# Routes +# ============================================================================= + +@bp.route("/login", methods=["GET", "POST"]) +@csrf_protect +async def login(): + """Admin login page.""" + admin_password = get_admin_password() + + if not admin_password: + await flash("Admin access not configured. Set ADMIN_PASSWORD env var.", "error") + return redirect(url_for("public.landing")) + + if session.get("is_admin"): + return redirect(url_for("admin.index")) + + if request.method == "POST": + form = await request.form + password = form.get("password", "") + + if secrets.compare_digest(password, admin_password): + session["is_admin"] = True + await flash("Welcome, admin!", "success") + return redirect(url_for("admin.index")) + else: + await flash("Invalid password.", "error") + + return await render_template("login.html") + + +@bp.route("/logout", methods=["POST"]) +@csrf_protect +async def logout(): + """Admin logout.""" + session.pop("is_admin", None) + await flash("Logged out of admin.", "info") + return redirect(url_for("admin.login")) + + +@bp.route("/") +@admin_required +async def index(): + """Admin dashboard.""" + stats = await get_dashboard_stats() + recent_users = await get_users(limit=10) + failed_tasks = await get_failed_tasks() + + return await render_template( + "index.html", + stats=stats, + recent_users=recent_users, + failed_tasks=failed_tasks, + ) + + +@bp.route("/users") +@admin_required +async def users(): + """User list.""" + search = request.args.get("search", "").strip() + page = int(request.args.get("page", 1)) + per_page = 50 + offset = (page - 1) * per_page + + user_list = await get_users(limit=per_page, offset=offset, search=search or None) + + return await render_template( + "users.html", + users=user_list, + search=search, + page=page, + ) + + +@bp.route("/users/") +@admin_required +async def user_detail(user_id: int): + """User detail page.""" + user = await get_user_by_id(user_id) + if not user: + await flash("User not found.", "error") + return redirect(url_for("admin.users")) + + return await render_template("user_detail.html", user=user) + + +@bp.route("/users//impersonate", methods=["POST"]) +@admin_required +@csrf_protect +async def impersonate(user_id: int): + """Impersonate a user (login as them).""" + user = await get_user_by_id(user_id) + if not user: + await flash("User not found.", "error") + return redirect(url_for("admin.users")) + + # Store admin session so we can return + session["admin_impersonating"] = True + session["user_id"] = user_id + + await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning") + return redirect(url_for("dashboard.index")) + + +@bp.route("/stop-impersonating", methods=["POST"]) +@csrf_protect +async def stop_impersonating(): + """Stop impersonating and return to admin.""" + session.pop("user_id", None) + session.pop("admin_impersonating", None) + await flash("Stopped impersonating.", "info") + return redirect(url_for("admin.index")) + + +@bp.route("/tasks") +@admin_required +async def tasks(): + """Task queue management.""" + task_list = await get_recent_tasks(limit=100) + failed = await get_failed_tasks() + + return await render_template( + "tasks.html", + tasks=task_list, + failed_tasks=failed, + ) + + +@bp.route("/tasks//retry", methods=["POST"]) +@admin_required +@csrf_protect +async def task_retry(task_id: int): + """Retry a failed task.""" + success = await retry_task(task_id) + if success: + await flash("Task queued for retry.", "success") + else: + await flash("Could not retry task.", "error") + return redirect(url_for("admin.tasks")) + + +@bp.route("/tasks//delete", methods=["POST"]) +@admin_required +@csrf_protect +async def task_delete(task_id: int): + """Delete a task.""" + success = await delete_task(task_id) + if success: + await flash("Task deleted.", "success") + else: + await flash("Could not delete task.", "error") + return redirect(url_for("admin.tasks")) diff --git a/web/src/beanflows/admin/templates/index.html b/web/src/beanflows/admin/templates/index.html new file mode 100644 index 0000000..8c25d87 --- /dev/null +++ b/web/src/beanflows/admin/templates/index.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Admin Dashboard

+ {% if session.get('admin_impersonating') %} + Currently impersonating a user +
+ + +
+ {% endif %} +
+
+ + +
+
+ + +
+
+
Total Users
+

{{ stats.users_total }}

+ +{{ stats.users_today }} today, +{{ stats.users_week }} this week +
+ +
+
Active Subscriptions
+

{{ stats.active_subscriptions }}

+
+ +
+
Task Queue
+

{{ stats.tasks_pending }} pending

+ {% if stats.tasks_failed > 0 %} + {{ stats.tasks_failed }} failed + {% else %} + 0 failed + {% endif %} +
+
+ + + + +
+ +
+

Recent Users

+
+ {% if recent_users %} + + + + + + + + + + {% for u in recent_users %} + + + + + + {% endfor %} + +
EmailPlanJoined
+ {{ u.email }} + {{ u.plan or 'free' }}{{ u.created_at[:10] }}
+ View all → + {% else %} +

No users yet.

+ {% endif %} +
+
+ + +
+

Failed Tasks

+
+ {% if failed_tasks %} + + + + + + + + + + {% for task in failed_tasks[:5] %} + + + + + + {% endfor %} + +
TaskError
{{ task.task_name }}{{ task.error[:50] }}... +
+ + +
+
+ View all → + {% else %} +

✓ No failed tasks

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/login.html b/web/src/beanflows/admin/templates/login.html new file mode 100644 index 0000000..6de0942 --- /dev/null +++ b/web/src/beanflows/admin/templates/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Admin Login

+
+ +
+ + + + + +
+
+
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/tasks.html b/web/src/beanflows/admin/templates/tasks.html new file mode 100644 index 0000000..e72af3f --- /dev/null +++ b/web/src/beanflows/admin/templates/tasks.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Task Queue

+ ← Dashboard +
+ + + {% if failed_tasks %} +
+

Failed Tasks ({{ failed_tasks | length }})

+
+ + + + + + + + + + + + + {% for task in failed_tasks %} + + + + + + + + + {% endfor %} + +
IDTaskErrorRetriesCreated
{{ task.id }}{{ task.task_name }} +
+ {{ task.error[:40] if task.error else 'No error' }}... +
{{ task.error }}
+
+
{{ task.retries }}{{ task.created_at[:16] }} +
+
+ + +
+
+ + +
+
+
+
+
+ {% endif %} + + +
+

Recent Tasks

+
+ {% if tasks %} + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
IDTaskStatusRun AtCreatedCompleted
{{ task.id }}{{ task.task_name }} + {% if task.status == 'complete' %} + ✓ complete + {% elif task.status == 'failed' %} + ✗ failed + {% elif task.status == 'pending' %} + ○ pending + {% else %} + {{ task.status }} + {% endif %} + {{ task.run_at[:16] if task.run_at else '-' }}{{ task.created_at[:16] }}{{ task.completed_at[:16] if task.completed_at else '-' }}
+ {% else %} +

No tasks in queue.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/user_detail.html b/web/src/beanflows/admin/templates/user_detail.html new file mode 100644 index 0000000..694caac --- /dev/null +++ b/web/src/beanflows/admin/templates/user_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

{{ user.email }}

+ ← Users +
+ +
+ +
+

User Info

+
+
ID
+
{{ user.id }}
+ +
Email
+
{{ user.email }}
+ +
Name
+
{{ user.name or '-' }}
+ +
Created
+
{{ user.created_at }}
+ +
Last Login
+
{{ user.last_login_at or 'Never' }}
+
+
+ + +
+

Subscription

+
+
Plan
+
+ {% if user.plan %} + {{ user.plan }} + {% else %} + free + {% endif %} +
+ +
Status
+
{{ user.sub_status or 'N/A' }}
+ + {% if user.stripe_customer_id %} +
Stripe Customer
+
+ + {{ user.stripe_customer_id }} + +
+ {% endif %} +
+
+
+ + +
+

Actions

+
+
+ + +
+
+
+
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/users.html b/web/src/beanflows/admin/templates/users.html new file mode 100644 index 0000000..15cd27b --- /dev/null +++ b/web/src/beanflows/admin/templates/users.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Users

+ ← Dashboard +
+ + +
+
+ + +
+
+ + +
+ {% if users %} + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
IDEmailNamePlanJoinedLast Login
{{ u.id }}{{ u.email }}{{ u.name or '-' }} + {% if u.plan %} + {{ u.plan }} + {% else %} + free + {% endif %} + {{ u.created_at[:10] }}{{ u.last_login_at[:10] if u.last_login_at else 'Never' }} +
+ + +
+
+ + +
+ {% if page > 1 %} + ← Previous + {% endif %} + Page {{ page }} + {% if users | length == 50 %} + Next → + {% endif %} +
+ {% else %} +

No users found.

+ {% endif %} +
+
+{% endblock %} diff --git a/web/src/beanflows/analytics.py b/web/src/beanflows/analytics.py new file mode 100644 index 0000000..5cea45f --- /dev/null +++ b/web/src/beanflows/analytics.py @@ -0,0 +1,220 @@ +""" +DuckDB analytics data access layer. + +Bridge between the async Quart app and sync DuckDB reads. +All queries run via asyncio.to_thread() against a read-only connection. +""" +import asyncio +import os + +import duckdb + +# Coffee (Green) commodity code in USDA PSD +COFFEE_COMMODITY_CODE = 711100 + +# Metrics safe for user-facing queries (prevents SQL injection in dynamic column refs) +ALLOWED_METRICS = frozenset({ + "Production", + "Imports", + "Exports", + "Total_Distribution", + "Ending_Stocks", + "Beginning_Stocks", + "Total_Supply", + "Domestic_Consumption", + "Net_Supply", + "Trade_Balance", + "Supply_Demand_Balance", + "Stock_to_Use_Ratio_pct", + "Production_YoY_pct", +}) + +_conn: duckdb.DuckDBPyConnection | None = None + + +def open_analytics_db() -> None: + """Open read-only DuckDB connection.""" + global _conn + db_path = os.getenv("DUCKDB_PATH", "") + assert db_path, "DUCKDB_PATH environment variable must be set" + _conn = duckdb.connect(db_path, read_only=True) + + +def close_analytics_db() -> None: + """Close DuckDB connection.""" + global _conn + if _conn: + _conn.close() + _conn = None + + +async def fetch_analytics(sql: str, params: list | None = None) -> list[dict]: + """Run a read-only DuckDB query off the event loop. Returns list of dicts.""" + assert _conn is not None, "Analytics DB not initialized — call open_analytics_db() first" + + def _query(): + result = _conn.execute(sql, params or []) + columns = [desc[0] for desc in result.description] + return [dict(zip(columns, row)) for row in result.fetchall()] + + return await asyncio.to_thread(_query) + + +def _validate_metrics(metrics: list[str]) -> list[str]: + """Filter metrics to allowed set. Returns validated list.""" + valid = [m for m in metrics if m in ALLOWED_METRICS] + assert valid, f"No valid metrics in {metrics}. Allowed: {sorted(ALLOWED_METRICS)}" + return valid + + +# ============================================================================= +# Query Functions +# ============================================================================= + + +async def get_available_commodities() -> list[dict]: + """Distinct commodity list from serving layer.""" + return await fetch_analytics( + """ + SELECT DISTINCT commodity_code, commodity_name + FROM serving.commodity_metrics + WHERE country_code IS NOT NULL + ORDER BY commodity_name + """ + ) + + +async def get_global_time_series( + commodity_code: int, + metrics: list[str], + start_year: int | None = None, + end_year: int | None = None, +) -> list[dict]: + """Global supply/demand time series by market_year for a commodity.""" + metrics = _validate_metrics(metrics) + cols = ", ".join(metrics) + + where_parts = ["country_name = 'Global'", "commodity_code = ?"] + params: list = [commodity_code] + + if start_year is not None: + where_parts.append("market_year >= ?") + params.append(start_year) + if end_year is not None: + where_parts.append("market_year <= ?") + params.append(end_year) + + where_clause = " AND ".join(where_parts) + + return await fetch_analytics( + f""" + SELECT market_year, {cols} + FROM serving.commodity_metrics + WHERE {where_clause} + ORDER BY market_year + """, + params, + ) + + +async def get_top_countries( + commodity_code: int, + metric: str, + limit: int = 10, +) -> list[dict]: + """Country ranking for latest market year by a single metric.""" + metric = _validate_metrics([metric])[0] + + return await fetch_analytics( + f""" + WITH latest AS ( + SELECT MAX(market_year) AS max_year + FROM serving.commodity_metrics + WHERE commodity_code = ? AND country_code IS NOT NULL + ) + SELECT country_name, country_code, market_year, {metric} + FROM serving.commodity_metrics, latest + WHERE commodity_code = ? + AND country_code IS NOT NULL + AND market_year = latest.max_year + ORDER BY {metric} DESC + LIMIT ? + """, + [commodity_code, commodity_code, limit], + ) + + +async def get_stock_to_use_trend(commodity_code: int) -> list[dict]: + """Global stock-to-use ratio over time.""" + return await fetch_analytics( + """ + SELECT market_year, Stock_to_Use_Ratio_pct + FROM serving.commodity_metrics + WHERE commodity_code = ? + AND country_name = 'Global' + ORDER BY market_year + """, + [commodity_code], + ) + + +async def get_supply_demand_balance(commodity_code: int) -> list[dict]: + """Global supply-demand balance trend.""" + return await fetch_analytics( + """ + SELECT market_year, Production, Total_Distribution, Supply_Demand_Balance + FROM serving.commodity_metrics + WHERE commodity_code = ? + AND country_name = 'Global' + ORDER BY market_year + """, + [commodity_code], + ) + + +async def get_production_yoy_by_country( + commodity_code: int, + limit: int = 15, +) -> list[dict]: + """Latest YoY production changes by country.""" + return await fetch_analytics( + """ + WITH latest AS ( + SELECT MAX(market_year) AS max_year + FROM serving.commodity_metrics + WHERE commodity_code = ? AND country_code IS NOT NULL + ) + SELECT country_name, country_code, market_year, + Production, Production_YoY_pct + FROM serving.commodity_metrics, latest + WHERE commodity_code = ? + AND country_code IS NOT NULL + AND market_year = latest.max_year + AND Production > 0 + ORDER BY ABS(Production_YoY_pct) DESC + LIMIT ? + """, + [commodity_code, commodity_code, limit], + ) + + +async def get_country_comparison( + commodity_code: int, + country_codes: list[str], + metric: str, +) -> list[dict]: + """Multi-country time series for a single metric.""" + assert len(country_codes) <= 10, "Maximum 10 countries for comparison" + metric = _validate_metrics([metric])[0] + placeholders = ", ".join(["?"] * len(country_codes)) + + return await fetch_analytics( + f""" + SELECT country_name, country_code, market_year, {metric} + FROM serving.commodity_metrics + WHERE commodity_code = ? + AND country_code IN ({placeholders}) + ORDER BY country_name, market_year + """, + [commodity_code, *country_codes], + ) diff --git a/web/src/beanflows/api/routes.py b/web/src/beanflows/api/routes.py new file mode 100644 index 0000000..f32532c --- /dev/null +++ b/web/src/beanflows/api/routes.py @@ -0,0 +1,191 @@ +""" +API domain: REST API for commodity analytics with key authentication and rate limiting. +""" +import csv +import hashlib +import io +from datetime import datetime +from functools import wraps + +from quart import Blueprint, Response, g, jsonify, request + +from .. import analytics +from ..core import check_rate_limit, execute, fetch_one + +bp = Blueprint("api", __name__) + + +# ============================================================================= +# SQL Queries +# ============================================================================= + +async def verify_api_key(raw_key: str) -> dict | None: + """Verify API key and return key data with user info.""" + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + + result = await fetch_one( + """ + SELECT ak.*, u.email, u.id as user_id, + COALESCE(s.plan, 'free') as plan + FROM api_keys ak + JOIN users u ON u.id = ak.user_id + LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active' + WHERE ak.key_hash = ? AND ak.deleted_at IS NULL AND u.deleted_at IS NULL + """, + (key_hash,), + ) + + if result: + await execute( + "UPDATE api_keys SET last_used_at = ? WHERE id = ?", + (datetime.utcnow().isoformat(), result["id"]), + ) + + return result + + +async def log_api_request(user_id: int, endpoint: str, method: str) -> None: + """Log API request for analytics and rate limiting.""" + await execute( + """ + INSERT INTO api_requests (user_id, endpoint, method, created_at) + VALUES (?, ?, ?, ?) + """, + (user_id, endpoint, method, datetime.utcnow().isoformat()), + ) + + +# ============================================================================= +# Decorators +# ============================================================================= + +def api_key_required(scopes: list[str] = None): + """Require valid API key with optional scope check.""" + def decorator(f): + @wraps(f) + async def decorated(*args, **kwargs): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return jsonify({"error": "Missing API key"}), 401 + + raw_key = auth[7:] + key_data = await verify_api_key(raw_key) + + if not key_data: + return jsonify({"error": "Invalid API key"}), 401 + + # Check scopes + if scopes: + key_scopes = (key_data.get("scopes") or "").split(",") + if not any(s in key_scopes for s in scopes): + return jsonify({"error": "Insufficient permissions"}), 403 + + # Check plan allows API access + plan = key_data.get("plan", "free") + if plan == "free": + return jsonify({"error": "API access requires a Starter or Pro plan"}), 403 + + # Rate limiting + rate_key = f"api:{key_data['id']}" + allowed, info = await check_rate_limit(rate_key, limit=1000, window=3600) + + if not allowed: + response = jsonify({"error": "Rate limit exceeded", **info}) + response.headers["X-RateLimit-Limit"] = str(info["limit"]) + response.headers["X-RateLimit-Remaining"] = str(info["remaining"]) + response.headers["X-RateLimit-Reset"] = str(info["reset"]) + return response, 429 + + await log_api_request(key_data["user_id"], request.path, request.method) + + g.api_key = key_data + g.user_id = key_data["user_id"] + g.plan = plan + + return await f(*args, **kwargs) + return decorated + return decorator + + +# ============================================================================= +# Routes +# ============================================================================= + +@bp.route("/me") +@api_key_required() +async def me(): + """Get current user info.""" + return jsonify({ + "user_id": g.user_id, + "email": g.api_key["email"], + "plan": g.plan, + "key_name": g.api_key["name"], + "scopes": g.api_key["scopes"].split(","), + }) + + +@bp.route("/commodities") +@api_key_required(scopes=["read"]) +async def list_commodities(): + """List available commodities.""" + commodities = await analytics.get_available_commodities() + return jsonify({"commodities": commodities}) + + +@bp.route("/commodities//metrics") +@api_key_required(scopes=["read"]) +async def commodity_metrics(code: int): + """Time series metrics for a commodity. Query params: metrics, start_year, end_year.""" + raw_metrics = request.args.getlist("metrics") or ["Production", "Exports", "Imports", "Ending_Stocks"] + metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS] + if not metrics: + return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400 + + start_year = request.args.get("start_year", type=int) + end_year = request.args.get("end_year", type=int) + + data = await analytics.get_global_time_series(code, metrics, start_year, end_year) + return jsonify({"commodity_code": code, "metrics": metrics, "data": data}) + + +@bp.route("/commodities//countries") +@api_key_required(scopes=["read"]) +async def commodity_countries(code: int): + """Country ranking for a commodity. Query params: metric, limit.""" + metric = request.args.get("metric", "Production") + if metric not in analytics.ALLOWED_METRICS: + return jsonify({"error": f"Invalid metric. Allowed: {sorted(analytics.ALLOWED_METRICS)}"}), 400 + + limit = min(int(request.args.get("limit", 20)), 100) + + data = await analytics.get_top_countries(code, metric, limit) + return jsonify({"commodity_code": code, "metric": metric, "data": data}) + + +@bp.route("/commodities//metrics.csv") +@api_key_required(scopes=["read"]) +async def commodity_metrics_csv(code: int): + """CSV export of time series metrics.""" + if g.plan == "free": + return jsonify({"error": "CSV export requires a Starter or Pro plan"}), 403 + + raw_metrics = request.args.getlist("metrics") or [ + "Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution", + ] + metrics = [m for m in raw_metrics if m in analytics.ALLOWED_METRICS] + if not metrics: + return jsonify({"error": "No valid metrics"}), 400 + + data = await analytics.get_global_time_series(code, metrics) + + output = io.StringIO() + if data: + writer = csv.DictWriter(output, fieldnames=data[0].keys()) + writer.writeheader() + writer.writerows(data) + + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename=commodity_{code}_metrics.csv"}, + ) diff --git a/web/src/beanflows/app.py b/web/src/beanflows/app.py new file mode 100644 index 0000000..c9771e5 --- /dev/null +++ b/web/src/beanflows/app.py @@ -0,0 +1,123 @@ +""" +BeanFlows - Application factory and entry point. +""" +import os +from pathlib import Path + +from quart import Quart, g, session + +from .analytics import close_analytics_db, open_analytics_db +from .core import close_db, config, get_csrf_token, init_db, setup_request_id + + +def create_app() -> Quart: + """Create and configure the Quart application.""" + + # Get package directory for templates + pkg_dir = Path(__file__).parent + + app = Quart( + __name__, + template_folder=str(pkg_dir / "templates"), + static_folder=str(pkg_dir / "static"), + ) + + app.secret_key = config.SECRET_KEY + + # Session config + app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS + + # Database lifecycle + @app.before_serving + async def startup(): + await init_db() + if os.getenv("DUCKDB_PATH"): + open_analytics_db() + + @app.after_serving + async def shutdown(): + close_analytics_db() + await close_db() + + # Security headers + @app.after_request + async def add_security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + if not config.DEBUG: + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + # Load current user before each request + @app.before_request + async def load_user(): + g.user = None + user_id = session.get("user_id") + if user_id: + from .auth.routes import get_user_by_id + g.user = await get_user_by_id(user_id) + + # Template context globals + @app.context_processor + def inject_globals(): + from datetime import datetime + return { + "config": config, + "user": g.get("user"), + "now": datetime.utcnow(), + "csrf_token": get_csrf_token, + } + + # Health check + @app.route("/health") + async def health(): + from .analytics import _conn as duckdb_conn + from .analytics import fetch_analytics + from .core import fetch_one + result = {"status": "healthy", "sqlite": "ok", "duckdb": "ok"} + try: + await fetch_one("SELECT 1") + except Exception as e: + result["sqlite"] = str(e) + result["status"] = "unhealthy" + if duckdb_conn is not None: + try: + await fetch_analytics("SELECT 1") + except Exception as e: + result["duckdb"] = str(e) + result["status"] = "unhealthy" + else: + result["duckdb"] = "not configured" + status_code = 200 if result["status"] == "healthy" else 500 + return result, status_code + + # Register blueprints + from .admin.routes import bp as admin_bp + from .api.routes import bp as api_bp + from .auth.routes import bp as auth_bp + from .billing.routes import bp as billing_bp + from .dashboard.routes import bp as dashboard_bp + from .public.routes import bp as public_bp + + app.register_blueprint(public_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(dashboard_bp) + app.register_blueprint(billing_bp) + app.register_blueprint(api_bp, url_prefix="/api/v1") + app.register_blueprint(admin_bp) + + # Request ID tracking + setup_request_id(app) + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(debug=config.DEBUG, port=5000) diff --git a/web/src/beanflows/auth/routes.py b/web/src/beanflows/auth/routes.py new file mode 100644 index 0000000..8a6af9a --- /dev/null +++ b/web/src/beanflows/auth/routes.py @@ -0,0 +1,314 @@ +""" +Auth domain: magic link authentication, user management, decorators. +""" +import secrets +from functools import wraps +from datetime import datetime, timedelta +from pathlib import Path + +from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g + +from ..core import config, fetch_one, fetch_all, execute, csrf_protect + +# Blueprint with its own template folder +bp = Blueprint( + "auth", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/auth", +) + + +# ============================================================================= +# SQL Queries +# ============================================================================= + +async def get_user_by_id(user_id: int) -> dict | None: + """Get user by ID.""" + return await fetch_one( + "SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", + (user_id,) + ) + + +async def get_user_by_email(email: str) -> dict | None: + """Get user by email.""" + return await fetch_one( + "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", + (email.lower(),) + ) + + +async def create_user(email: str) -> int: + """Create new user, return ID.""" + now = datetime.utcnow().isoformat() + return await execute( + "INSERT INTO users (email, created_at) VALUES (?, ?)", + (email.lower(), now) + ) + + +async def update_user(user_id: int, **fields) -> None: + """Update user fields.""" + if not fields: + return + sets = ", ".join(f"{k} = ?" for k in fields.keys()) + values = list(fields.values()) + [user_id] + await execute(f"UPDATE users SET {sets} WHERE id = ?", tuple(values)) + + +async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int: + """Create auth token for user.""" + minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES + expires = datetime.utcnow() + timedelta(minutes=minutes) + return await execute( + "INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", + (user_id, token, expires.isoformat()) + ) + + +async def get_valid_token(token: str) -> dict | None: + """Get token if valid and not expired.""" + return await fetch_one( + """ + SELECT at.*, u.email + FROM auth_tokens at + JOIN users u ON u.id = at.user_id + WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL + """, + (token, datetime.utcnow().isoformat()) + ) + + +async def mark_token_used(token_id: int) -> None: + """Mark token as used.""" + await execute( + "UPDATE auth_tokens SET used_at = ? WHERE id = ?", + (datetime.utcnow().isoformat(), token_id) + ) + + +async def get_user_with_subscription(user_id: int) -> dict | None: + """Get user with their active subscription info.""" + return await fetch_one( + """ + SELECT u.*, s.plan, s.status as sub_status, s.current_period_end + FROM users u + LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active' + WHERE u.id = ? AND u.deleted_at IS NULL + """, + (user_id,) + ) + + +# ============================================================================= +# Decorators +# ============================================================================= + +def login_required(f): + """Require authenticated user.""" + @wraps(f) + async def decorated(*args, **kwargs): + if not g.get("user"): + await flash("Please sign in to continue.", "warning") + return redirect(url_for("auth.login", next=request.path)) + return await f(*args, **kwargs) + return decorated + + +def subscription_required(plans: list[str] = None): + """Require active subscription, optionally of specific plan(s).""" + def decorator(f): + @wraps(f) + async def decorated(*args, **kwargs): + if not g.get("user"): + await flash("Please sign in to continue.", "warning") + return redirect(url_for("auth.login")) + + user = await get_user_with_subscription(g.user["id"]) + if not user or not user.get("plan"): + await flash("Please subscribe to access this feature.", "warning") + return redirect(url_for("billing.pricing")) + + if plans and user["plan"] not in plans: + await flash(f"This feature requires a {' or '.join(plans)} plan.", "warning") + return redirect(url_for("billing.pricing")) + + return await f(*args, **kwargs) + return decorated + return decorator + + +# ============================================================================= +# Routes +# ============================================================================= + +@bp.route("/login", methods=["GET", "POST"]) +@csrf_protect +async def login(): + """Login page - request magic link.""" + if g.get("user"): + return redirect(url_for("dashboard.index")) + + if request.method == "POST": + form = await request.form + email = form.get("email", "").strip().lower() + + if not email or "@" not in email: + await flash("Please enter a valid email address.", "error") + return redirect(url_for("auth.login")) + + # Get or create user + user = await get_user_by_email(email) + if not user: + user_id = await create_user(email) + else: + user_id = user["id"] + + # Create magic link token + token = secrets.token_urlsafe(32) + await create_auth_token(user_id, token) + + # Queue email + from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token}) + + await flash("Check your email for the sign-in link!", "success") + return redirect(url_for("auth.magic_link_sent", email=email)) + + return await render_template("login.html") + + +@bp.route("/signup", methods=["GET", "POST"]) +@csrf_protect +async def signup(): + """Signup page - same as login but with different messaging.""" + if g.get("user"): + return redirect(url_for("dashboard.index")) + + plan = request.args.get("plan", "free") + + if request.method == "POST": + form = await request.form + email = form.get("email", "").strip().lower() + selected_plan = form.get("plan", "free") + + if not email or "@" not in email: + await flash("Please enter a valid email address.", "error") + return redirect(url_for("auth.signup", plan=selected_plan)) + + # Check if user exists + user = await get_user_by_email(email) + if user: + await flash("Account already exists. Please sign in.", "info") + return redirect(url_for("auth.login")) + + # Create user + user_id = await create_user(email) + + # Create magic link token + token = secrets.token_urlsafe(32) + await create_auth_token(user_id, token) + + # Queue emails + from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token}) + await enqueue("send_welcome", {"email": email}) + + await flash("Check your email to complete signup!", "success") + return redirect(url_for("auth.magic_link_sent", email=email)) + + return await render_template("signup.html", plan=plan) + + +@bp.route("/verify") +async def verify(): + """Verify magic link token.""" + token = request.args.get("token") + + if not token: + await flash("Invalid or expired link.", "error") + return redirect(url_for("auth.login")) + + token_data = await get_valid_token(token) + + if not token_data: + await flash("Invalid or expired link. Please request a new one.", "error") + return redirect(url_for("auth.login")) + + # Mark token as used + await mark_token_used(token_data["id"]) + + # Update last login + await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat()) + + # Set session + session.permanent = True + session["user_id"] = token_data["user_id"] + + await flash("Successfully signed in!", "success") + + # Redirect to intended page or dashboard + next_url = request.args.get("next", url_for("dashboard.index")) + return redirect(next_url) + + +@bp.route("/logout", methods=["POST"]) +@csrf_protect +async def logout(): + """Log out user.""" + session.clear() + await flash("You have been signed out.", "info") + return redirect(url_for("public.landing")) + + +@bp.route("/magic-link-sent") +async def magic_link_sent(): + """Confirmation page after magic link sent.""" + email = request.args.get("email", "") + return await render_template("magic_link_sent.html", email=email) + + +@bp.route("/dev-login") +async def dev_login(): + """Instant login for development. Only works in DEBUG mode.""" + if not config.DEBUG: + return "Not available", 404 + + email = request.args.get("email", "dev@localhost") + + user = await get_user_by_email(email) + if not user: + user_id = await create_user(email) + else: + user_id = user["id"] + + session.permanent = True + session["user_id"] = user_id + + await flash(f"Dev login as {email}", "success") + return redirect(url_for("dashboard.index")) + + +@bp.route("/resend", methods=["POST"]) +@csrf_protect +async def resend(): + """Resend magic link.""" + form = await request.form + email = form.get("email", "").strip().lower() + + if not email: + await flash("Email address required.", "error") + return redirect(url_for("auth.login")) + + user = await get_user_by_email(email) + if user: + token = secrets.token_urlsafe(32) + await create_auth_token(user["id"], token) + + from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token}) + + # Always show success (don't reveal if email exists) + await flash("If that email is registered, we've sent a new link.", "success") + return redirect(url_for("auth.magic_link_sent", email=email)) diff --git a/web/src/beanflows/auth/templates/login.html b/web/src/beanflows/auth/templates/login.html new file mode 100644 index 0000000..89b9964 --- /dev/null +++ b/web/src/beanflows/auth/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Sign In

+

Enter your email to receive a sign-in link.

+
+ +
+ + + + + +
+ +
+ + Don't have an account? + Sign up + +
+
+
+{% endblock %} diff --git a/web/src/beanflows/auth/templates/magic_link_sent.html b/web/src/beanflows/auth/templates/magic_link_sent.html new file mode 100644 index 0000000..b733b1b --- /dev/null +++ b/web/src/beanflows/auth/templates/magic_link_sent.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Check Your Email

+
+ +

We've sent a sign-in link to:

+

{{ email }}

+ +

Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.

+ +
+ +
+ Didn't receive the email? +
    +
  • Check your spam folder
  • +
  • Make sure the email address is correct
  • +
  • Wait a minute and try again
  • +
+ +
+ + + +
+
+
+
+{% endblock %} diff --git a/web/src/beanflows/auth/templates/signup.html b/web/src/beanflows/auth/templates/signup.html new file mode 100644 index 0000000..a87ed09 --- /dev/null +++ b/web/src/beanflows/auth/templates/signup.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Create Account

+

Enter your email to get started.

+
+ +
+ + + + + + {% if plan and plan != 'free' %} + You'll be able to subscribe to the {{ plan | title }} plan after signing up. + {% endif %} + + +
+ +
+ + Already have an account? + Sign in + +
+
+
+{% endblock %} diff --git a/web/src/beanflows/billing/routes.py b/web/src/beanflows/billing/routes.py new file mode 100644 index 0000000..213a824 --- /dev/null +++ b/web/src/beanflows/billing/routes.py @@ -0,0 +1,275 @@ +""" +Billing domain: checkout, webhooks, subscription management. +Payment provider: paddle +""" + +import json +from datetime import datetime +from functools import wraps +from pathlib import Path + +from quart import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify, session + +import httpx + + +from ..core import config, fetch_one, fetch_all, execute + +from ..core import verify_hmac_signature + +from ..auth.routes import login_required + + + +# Blueprint with its own template folder +bp = Blueprint( + "billing", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/billing", +) + + +# ============================================================================= +# SQL Queries +# ============================================================================= + +async def get_subscription(user_id: int) -> dict | None: + """Get user's subscription.""" + return await fetch_one( + "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", + (user_id,) + ) + + +async def upsert_subscription( + user_id: int, + plan: str, + status: str, + provider_customer_id: str, + provider_subscription_id: str, + current_period_end: str = None, +) -> int: + """Create or update subscription.""" + now = datetime.utcnow().isoformat() + + customer_col = "paddle_customer_id" + subscription_col = "paddle_subscription_id" + + + existing = await fetch_one("SELECT id FROM subscriptions WHERE user_id = ?", (user_id,)) + + if existing: + await execute( + f"""UPDATE subscriptions + SET plan = ?, status = ?, {customer_col} = ?, {subscription_col} = ?, + current_period_end = ?, updated_at = ? + WHERE user_id = ?""", + (plan, status, provider_customer_id, provider_subscription_id, + current_period_end, now, user_id), + ) + return existing["id"] + else: + return await execute( + f"""INSERT INTO subscriptions + (user_id, plan, status, {customer_col}, {subscription_col}, + current_period_end, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (user_id, plan, status, provider_customer_id, provider_subscription_id, + current_period_end, now, now), + ) + + + +async def get_subscription_by_provider_id(subscription_id: str) -> dict | None: + return await fetch_one( + "SELECT * FROM subscriptions WHERE paddle_subscription_id = ?", + (subscription_id,) + ) + + + +async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None: + """Update subscription status by provider subscription ID.""" + extra["updated_at"] = datetime.utcnow().isoformat() + extra["status"] = status + sets = ", ".join(f"{k} = ?" for k in extra) + values = list(extra.values()) + + values.append(provider_subscription_id) + await execute(f"UPDATE subscriptions SET {sets} WHERE paddle_subscription_id = ?", tuple(values)) + + + +async def can_access_feature(user_id: int, feature: str) -> bool: + """Check if user can access a feature based on their plan.""" + sub = await get_subscription(user_id) + plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free" + return feature in config.PLAN_FEATURES.get(plan, []) + + +async def is_within_limits(user_id: int, resource: str, current_count: int) -> bool: + """Check if user is within their plan limits.""" + sub = await get_subscription(user_id) + plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free" + limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0) + if limit == -1: + return True + return current_count < limit + + +# ============================================================================= +# Access Gating +# ============================================================================= + +def subscription_required(allowed=("active", "on_trial", "cancelled")): + """Decorator to gate routes behind active subscription.""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + if "user_id" not in session: + return redirect(url_for("auth.login")) + sub = await get_subscription(session["user_id"]) + if not sub or sub["status"] not in allowed: + return redirect(url_for("billing.pricing")) + return await func(*args, **kwargs) + return wrapper + return decorator + + +# ============================================================================= +# Routes +# ============================================================================= + +@bp.route("/pricing") +async def pricing(): + """Pricing page.""" + user_sub = None + if "user_id" in session: + user_sub = await get_subscription(session["user_id"]) + return await render_template("pricing.html", subscription=user_sub) + + +@bp.route("/success") +@login_required +async def success(): + """Checkout success page.""" + return await render_template("success.html") + + + +# ============================================================================= +# Paddle Implementation +# ============================================================================= + +@bp.route("/checkout/", methods=["POST"]) +@login_required +async def checkout(plan: str): + """Create Paddle checkout via API.""" + price_id = config.PADDLE_PRICES.get(plan) + if not price_id: + await flash("Invalid plan selected.", "error") + return redirect(url_for("billing.pricing")) + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.paddle.com/transactions", + headers={ + "Authorization": f"Bearer {config.PADDLE_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "items": [{"price_id": price_id, "quantity": 1}], + "custom_data": {"user_id": str(g.user["id"]), "plan": plan}, + "checkout": { + "url": f"{config.BASE_URL}/billing/success", + }, + }, + ) + response.raise_for_status() + + checkout_url = response.json()["data"]["checkout"]["url"] + return redirect(checkout_url) + + +@bp.route("/manage", methods=["POST"]) +@login_required +async def manage(): + """Redirect to Paddle customer portal.""" + sub = await get_subscription(g.user["id"]) + if not sub or not sub.get("paddle_subscription_id"): + await flash("No active subscription found.", "error") + return redirect(url_for("dashboard.settings")) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}", + headers={"Authorization": f"Bearer {config.PADDLE_API_KEY}"}, + ) + response.raise_for_status() + + portal_url = response.json()["data"]["management_urls"]["update_payment_method"] + return redirect(portal_url) + + +@bp.route("/cancel", methods=["POST"]) +@login_required +async def cancel(): + """Cancel subscription via Paddle API.""" + sub = await get_subscription(g.user["id"]) + if sub and sub.get("paddle_subscription_id"): + async with httpx.AsyncClient() as client: + await client.post( + f"https://api.paddle.com/subscriptions/{sub['paddle_subscription_id']}/cancel", + headers={ + "Authorization": f"Bearer {config.PADDLE_API_KEY}", + "Content-Type": "application/json", + }, + json={"effective_from": "next_billing_period"}, + ) + return redirect(url_for("dashboard.settings")) + + +@bp.route("/webhook/paddle", methods=["POST"]) +async def webhook(): + """Handle Paddle webhooks.""" + payload = await request.get_data() + sig = request.headers.get("Paddle-Signature", "") + + if not verify_hmac_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET): + return jsonify({"error": "Invalid signature"}), 400 + + event = json.loads(payload) + event_type = event.get("event_type") + data = event.get("data", {}) + custom_data = data.get("custom_data", {}) + user_id = custom_data.get("user_id") + + if event_type == "subscription.activated": + plan = custom_data.get("plan", "starter") + await upsert_subscription( + user_id=int(user_id) if user_id else 0, + plan=plan, + status="active", + provider_customer_id=str(data.get("customer_id", "")), + provider_subscription_id=data.get("id", ""), + current_period_end=data.get("current_billing_period", {}).get("ends_at"), + ) + + elif event_type == "subscription.updated": + await update_subscription_status( + data.get("id", ""), + status=data.get("status", "active"), + current_period_end=data.get("current_billing_period", {}).get("ends_at"), + ) + + elif event_type == "subscription.canceled": + await update_subscription_status(data.get("id", ""), status="cancelled") + + elif event_type == "subscription.past_due": + await update_subscription_status(data.get("id", ""), status="past_due") + + return jsonify({"received": True}) + + + diff --git a/web/src/beanflows/billing/templates/pricing.html b/web/src/beanflows/billing/templates/pricing.html new file mode 100644 index 0000000..99a819e --- /dev/null +++ b/web/src/beanflows/billing/templates/pricing.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} + +{% block title %}Pricing - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Simple, Transparent Pricing

+

Start free with coffee data. Upgrade when you need more.

+
+ +
+ +
+
+

Free

+

$0 /month

+
+
    +
  • Coffee dashboard
  • +
  • Last 5 years of data
  • +
  • Global & country charts
  • +
  • Community support
  • +
+
+ {% if user %} + {% if (user.plan or 'free') == 'free' %} + + {% else %} + + {% endif %} + {% else %} + Get Started + {% endif %} +
+
+ + +
+
+

Starter

+

TBD /month

+
+
    +
  • Full coffee history (18+ years)
  • +
  • CSV data export
  • +
  • REST API access (10k calls/mo)
  • +
  • Email support
  • +
+
+ {% if user %} + {% if (user.plan or 'free') == 'starter' %} + + {% else %} +
+ + +
+ {% endif %} + {% else %} + Get Started + {% endif %} +
+
+ + +
+
+

Pro

+

TBD /month

+
+
    +
  • All 65 USDA commodities
  • +
  • Unlimited API calls
  • +
  • CSV & API export
  • +
  • Priority support
  • +
+
+ {% if user %} + {% if (user.plan or 'free') == 'pro' %} + + {% else %} +
+ + +
+ {% endif %} + {% else %} + Get Started + {% endif %} +
+
+
+ + +
+

Frequently Asked Questions

+ +
+ Where does the data come from? +

All data comes from the USDA Production, Supply & Distribution (PSD) Online database, which is freely available. We process and transform it daily into analytics-ready metrics.

+
+ +
+ Can I change plans later? +

Yes. Upgrade or downgrade at any time. Changes take effect immediately with prorated billing.

+
+ +
+ What commodities are available on Pro? +

All 65 commodities tracked by USDA PSD, including coffee, cocoa, sugar, cotton, grains, oilseeds, and more.

+
+ +
+ How do I cancel? +

Cancel anytime from your dashboard settings. You keep access until the end of your billing period.

+
+
+
+{% endblock %} diff --git a/web/src/beanflows/billing/templates/success.html b/web/src/beanflows/billing/templates/success.html new file mode 100644 index 0000000..8190b6e --- /dev/null +++ b/web/src/beanflows/billing/templates/success.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Success! - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

🎉 Welcome Aboard!

+
+ +

Your subscription is now active. You have full access to all features included in your plan.

+ +

+ Go to Dashboard +

+ +
+ +

+ Need to manage your subscription? Visit + account settings. +

+
+
+{% endblock %} diff --git a/web/src/beanflows/core.py b/web/src/beanflows/core.py new file mode 100644 index 0000000..5caa7e8 --- /dev/null +++ b/web/src/beanflows/core.py @@ -0,0 +1,334 @@ +""" +Core infrastructure: database, config, email, and shared utilities. +""" +import os +import secrets +import hashlib +import hmac +import aiosqlite +import httpx +from pathlib import Path +from functools import wraps +from datetime import datetime, timedelta +from contextvars import ContextVar +from quart import request, session, g + +# ============================================================================= +# Configuration +# ============================================================================= + +class Config: + APP_NAME: str = os.getenv("APP_NAME", "BeanFlows") + SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production") + BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5000") + DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + + DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db") + + MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15")) + SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30")) + + PAYMENT_PROVIDER: str = "paddle" + + PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "") + PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "") + PADDLE_PRICES: dict = { + "starter": os.getenv("PADDLE_PRICE_STARTER", ""), + "pro": os.getenv("PADDLE_PRICE_PRO", ""), + } + + RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "") + EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@example.com") + + RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) + RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) + + PLAN_FEATURES: dict = { + "free": ["dashboard", "coffee_only", "limited_history"], + "starter": ["dashboard", "coffee_only", "full_history", "export", "api"], + "pro": ["dashboard", "all_commodities", "full_history", "export", "api", "priority_support"], + } + + PLAN_LIMITS: dict = { + "free": {"commodities": 1, "history_years": 5, "api_calls": 0}, + "starter": {"commodities": 1, "history_years": -1, "api_calls": 10000}, + "pro": {"commodities": -1, "history_years": -1, "api_calls": -1}, # -1 = unlimited + } + + +config = Config() + +# ============================================================================= +# Database +# ============================================================================= + +_db: aiosqlite.Connection | None = None + + +async def init_db(path: str = None) -> None: + """Initialize database connection with WAL mode.""" + global _db + db_path = path or config.DATABASE_PATH + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + _db = await aiosqlite.connect(db_path) + _db.row_factory = aiosqlite.Row + + await _db.execute("PRAGMA journal_mode=WAL") + await _db.execute("PRAGMA foreign_keys=ON") + await _db.execute("PRAGMA busy_timeout=5000") + await _db.execute("PRAGMA synchronous=NORMAL") + await _db.execute("PRAGMA cache_size=-64000") + await _db.execute("PRAGMA temp_store=MEMORY") + await _db.execute("PRAGMA mmap_size=268435456") + await _db.commit() + + +async def close_db() -> None: + """Close database connection.""" + global _db + if _db: + await _db.execute("PRAGMA wal_checkpoint(TRUNCATE)") + await _db.close() + _db = None + + +async def get_db() -> aiosqlite.Connection: + """Get database connection.""" + if _db is None: + await init_db() + return _db + + +async def fetch_one(sql: str, params: tuple = ()) -> dict | None: + """Fetch a single row as dict.""" + db = await get_db() + async with db.execute(sql, params) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + +async def fetch_all(sql: str, params: tuple = ()) -> list[dict]: + """Fetch all rows as list of dicts.""" + db = await get_db() + async with db.execute(sql, params) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def execute(sql: str, params: tuple = ()) -> int: + """Execute SQL and return lastrowid.""" + db = await get_db() + async with db.execute(sql, params) as cursor: + await db.commit() + return cursor.lastrowid + + +async def execute_many(sql: str, params_list: list[tuple]) -> None: + """Execute SQL for multiple parameter sets.""" + db = await get_db() + await db.executemany(sql, params_list) + await db.commit() + + +class transaction: + """Async context manager for transactions.""" + + async def __aenter__(self): + self.db = await get_db() + return self.db + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + await self.db.commit() + else: + await self.db.rollback() + return False + +# ============================================================================= +# Email +# ============================================================================= + +async def send_email(to: str, subject: str, html: str, text: str = None) -> bool: + """Send email via Resend API.""" + if not config.RESEND_API_KEY: + print(f"[EMAIL] Would send to {to}: {subject}") + return True + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.resend.com/emails", + headers={"Authorization": f"Bearer {config.RESEND_API_KEY}"}, + json={ + "from": config.EMAIL_FROM, + "to": to, + "subject": subject, + "html": html, + "text": text or html, + }, + ) + return response.status_code == 200 + +# ============================================================================= +# CSRF Protection +# ============================================================================= + +def get_csrf_token() -> str: + """Get or create CSRF token for current session.""" + if "csrf_token" not in session: + session["csrf_token"] = secrets.token_urlsafe(32) + return session["csrf_token"] + + +def validate_csrf_token(token: str) -> bool: + """Validate CSRF token.""" + return token and secrets.compare_digest(token, session.get("csrf_token", "")) + + +def csrf_protect(f): + """Decorator to require valid CSRF token for POST requests.""" + @wraps(f) + async def decorated(*args, **kwargs): + if request.method == "POST": + form = await request.form + token = form.get("csrf_token") or request.headers.get("X-CSRF-Token") + if not validate_csrf_token(token): + return {"error": "Invalid CSRF token"}, 403 + return await f(*args, **kwargs) + return decorated + +# ============================================================================= +# Rate Limiting (SQLite-based) +# ============================================================================= + +async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]: + """ + Check if rate limit exceeded. Returns (is_allowed, info). + Uses SQLite for storage - no Redis needed. + """ + limit = limit or config.RATE_LIMIT_REQUESTS + window = window or config.RATE_LIMIT_WINDOW + now = datetime.utcnow() + window_start = now - timedelta(seconds=window) + + # Clean old entries and count recent + await execute( + "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", + (key, window_start.isoformat()) + ) + + result = await fetch_one( + "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", + (key, window_start.isoformat()) + ) + count = result["count"] if result else 0 + + info = { + "limit": limit, + "remaining": max(0, limit - count - 1), + "reset": int((window_start + timedelta(seconds=window)).timestamp()), + } + + if count >= limit: + return False, info + + # Record this request + await execute( + "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", + (key, now.isoformat()) + ) + + return True, info + + +def rate_limit(limit: int = None, window: int = None, key_func=None): + """Decorator for rate limiting routes.""" + def decorator(f): + @wraps(f) + async def decorated(*args, **kwargs): + if key_func: + key = key_func() + else: + key = f"ip:{request.remote_addr}" + + allowed, info = await check_rate_limit(key, limit, window) + + if not allowed: + response = {"error": "Rate limit exceeded", **info} + return response, 429 + + return await f(*args, **kwargs) + return decorated + return decorator + +# ============================================================================= +# Request ID Tracking +# ============================================================================= + +request_id_var: ContextVar[str] = ContextVar("request_id", default="") + + +def get_request_id() -> str: + """Get current request ID.""" + return request_id_var.get() + + +def setup_request_id(app): + """Setup request ID middleware.""" + @app.before_request + async def set_request_id(): + rid = request.headers.get("X-Request-ID") or secrets.token_hex(8) + request_id_var.set(rid) + g.request_id = rid + + @app.after_request + async def add_request_id_header(response): + response.headers["X-Request-ID"] = get_request_id() + return response + +# ============================================================================= +# Webhook Signature Verification +# ============================================================================= + + +def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool: + """Verify HMAC-SHA256 webhook signature.""" + expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return hmac.compare_digest(signature, expected) + + +# ============================================================================= +# Soft Delete Helpers +# ============================================================================= + +async def soft_delete(table: str, id: int) -> bool: + """Mark record as deleted.""" + result = await execute( + f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", + (datetime.utcnow().isoformat(), id) + ) + return result > 0 + + +async def restore(table: str, id: int) -> bool: + """Restore soft-deleted record.""" + result = await execute( + f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", + (id,) + ) + return result > 0 + + +async def hard_delete(table: str, id: int) -> bool: + """Permanently delete record.""" + result = await execute(f"DELETE FROM {table} WHERE id = ?", (id,)) + return result > 0 + + +async def purge_deleted(table: str, days: int = 30) -> int: + """Purge records deleted more than X days ago.""" + cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() + return await execute( + f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", + (cutoff,) + ) diff --git a/web/src/beanflows/dashboard/routes.py b/web/src/beanflows/dashboard/routes.py new file mode 100644 index 0000000..f3cc834 --- /dev/null +++ b/web/src/beanflows/dashboard/routes.py @@ -0,0 +1,246 @@ +""" +Dashboard domain: coffee analytics dashboard, settings, API keys. +""" +import asyncio +import hashlib +import secrets +from datetime import datetime +from pathlib import Path + +from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for + +from .. import analytics +from ..auth.routes import get_user_with_subscription, login_required, update_user +from ..billing.routes import get_subscription +from ..core import csrf_protect, execute, fetch_all, fetch_one, soft_delete + +# Blueprint with its own template folder +bp = Blueprint( + "dashboard", + __name__, + template_folder=str(Path(__file__).parent / "templates"), + url_prefix="/dashboard", +) + + +# ============================================================================= +# SQL Queries +# ============================================================================= + +async def get_user_stats(user_id: int) -> dict: + """Get dashboard stats for user.""" + api_calls = await fetch_one( + """ + SELECT COUNT(*) as count FROM api_requests + WHERE user_id = ? AND created_at > date('now', '-30 days') + """, + (user_id,), + ) + + return { + "api_calls": api_calls["count"] if api_calls else 0, + } + + +# API Key queries +async def create_api_key(user_id: int, name: str, scopes: list[str] = None) -> tuple[str, int]: + """Create API key. Returns (raw_key, key_id) - raw_key shown only once.""" + raw_key = f"sk_{secrets.token_urlsafe(32)}" + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + prefix = raw_key[:12] + + now = datetime.utcnow().isoformat() + key_id = await execute( + """ + INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (user_id, name, key_hash, prefix, ",".join(scopes or ["read"]), now), + ) + + return raw_key, key_id + + +async def get_user_api_keys(user_id: int) -> list[dict]: + """Get all API keys for user (without hashes).""" + return await fetch_all( + """ + SELECT id, name, key_prefix, scopes, created_at, last_used_at + FROM api_keys + WHERE user_id = ? AND deleted_at IS NULL + ORDER BY created_at DESC + """, + (user_id,), + ) + + +async def delete_api_key(key_id: int, user_id: int) -> bool: + """Delete API key (soft delete).""" + result = await execute( + """ + UPDATE api_keys + SET deleted_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL + """, + (datetime.utcnow().isoformat(), key_id, user_id), + ) + return result > 0 + + +# ============================================================================= +# Routes +# ============================================================================= + +@bp.route("/") +@login_required +async def index(): + """Coffee analytics dashboard.""" + user = await get_user_with_subscription(g.user["id"]) + stats = await get_user_stats(g.user["id"]) + plan = user.get("plan") or "free" + + # Fetch all analytics data in parallel + time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather( + analytics.get_global_time_series( + analytics.COFFEE_COMMODITY_CODE, + ["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"], + ), + analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10), + analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE), + analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE), + analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15), + ) + + # Latest global snapshot for key metric cards + latest = time_series[-1] if time_series else {} + + # Apply free plan history limit (last 5 years) + if plan == "free" and time_series: + max_year = time_series[-1]["market_year"] + cutoff_year = max_year - 5 + time_series = [r for r in time_series if r["market_year"] >= cutoff_year] + stu_trend = [r for r in stu_trend if r["market_year"] >= cutoff_year] + balance = [r for r in balance if r["market_year"] >= cutoff_year] + + return await render_template( + "index.html", + user=user, + stats=stats, + plan=plan, + latest=latest, + time_series=time_series, + top_producers=top_producers, + stu_trend=stu_trend, + balance=balance, + yoy=yoy, + ) + + +@bp.route("/countries") +@login_required +async def countries(): + """Country comparison page.""" + user = await get_user_with_subscription(g.user["id"]) + plan = user.get("plan") or "free" + + # Get available countries for coffee + all_countries = await analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=50) + + # Parse query params + selected_codes = request.args.getlist("country") + metric = request.args.get("metric", "Production") + + comparison_data = [] + if selected_codes: + selected_codes = selected_codes[:10] # cap at 10 + comparison_data = await analytics.get_country_comparison( + analytics.COFFEE_COMMODITY_CODE, selected_codes, metric + ) + + # HTMX partial: return just the chart data as JSON + if request.headers.get("HX-Request"): + return jsonify({"data": comparison_data, "metric": metric}) + + return await render_template( + "countries.html", + user=user, + plan=plan, + all_countries=all_countries, + selected_codes=selected_codes, + metric=metric, + comparison_data=comparison_data, + ) + + +@bp.route("/settings", methods=["GET", "POST"]) +@login_required +@csrf_protect +async def settings(): + """User settings page.""" + if request.method == "POST": + form = await request.form + + # Update user settings + await update_user( + g.user["id"], + name=form.get("name", "").strip() or None, + updated_at=datetime.utcnow().isoformat(), + ) + + await flash("Settings saved!", "success") + return redirect(url_for("dashboard.settings")) + + user = await get_user_with_subscription(g.user["id"]) + subscription = await get_subscription(g.user["id"]) + api_keys = await get_user_api_keys(g.user["id"]) + + return await render_template( + "settings.html", + user=user, + subscription=subscription, + api_keys=api_keys, + ) + + +@bp.route("/api-keys", methods=["POST"]) +@login_required +@csrf_protect +async def create_key(): + """Create new API key.""" + form = await request.form + name = form.get("name", "").strip() or "Untitled Key" + scopes = form.getlist("scopes") or ["read"] + + raw_key, key_id = await create_api_key(g.user["id"], name, scopes) + + await flash(f"API key created! Copy it now, it won't be shown again: {raw_key}", "success") + return redirect(url_for("dashboard.settings") + "#api-keys") + + +@bp.route("/api-keys//delete", methods=["POST"]) +@login_required +@csrf_protect +async def delete_key(key_id: int): + """Delete API key.""" + success = await delete_api_key(key_id, g.user["id"]) + + if success: + await flash("API key deleted.", "success") + else: + await flash("Could not delete API key.", "error") + + return redirect(url_for("dashboard.settings") + "#api-keys") + + +@bp.route("/delete-account", methods=["POST"]) +@login_required +@csrf_protect +async def delete_account(): + """Delete user account (soft delete).""" + from quart import session + + await soft_delete("users", g.user["id"]) + session.clear() + + await flash("Your account has been deleted.", "info") + return redirect(url_for("public.landing")) diff --git a/web/src/beanflows/dashboard/templates/countries.html b/web/src/beanflows/dashboard/templates/countries.html new file mode 100644 index 0000000..297d495 --- /dev/null +++ b/web/src/beanflows/dashboard/templates/countries.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} + +{% block title %}Country Comparison - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Country Comparison

+

Compare coffee metrics across producing and consuming countries.

+
+ + +
+
+ + +
+
+ + + {% if comparison_data %} +
+ +
+ {% else %} +
+

Select countries above to see the comparison chart.

+
+ {% endif %} + + Back to Dashboard +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/src/beanflows/dashboard/templates/index.html b/web/src/beanflows/dashboard/templates/index.html new file mode 100644 index 0000000..9a55c39 --- /dev/null +++ b/web/src/beanflows/dashboard/templates/index.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Coffee Dashboard

+

Welcome back{% if user.name %}, {{ user.name }}{% endif %}! Global coffee market data from USDA PSD.

+
+ + +
+
+
Global Production (latest year)
+

+ {{ "{:,.0f}".format(latest.get("Production", 0)) }} +

+ 1,000 60-kg bags +
+ +
+
Stock-to-Use Ratio
+

+ {% if stu_trend %} + {{ "{:.1f}".format(stu_trend[-1].get("Stock_to_Use_Ratio_pct", 0)) }}% + {% else %} + -- + {% endif %} +

+ Ending stocks / consumption +
+ +
+
Trade Balance
+

+ {{ "{:,.0f}".format(latest.get("Exports", 0) - latest.get("Imports", 0)) }} +

+ Exports minus imports +
+ +
+
Your Plan
+

{{ plan | title }}

+ + {% if plan == "free" %} + Upgrade for full history + {% else %} + {{ stats.api_calls }} API calls (30d) + {% endif %} + +
+
+ + +
+

Global Supply & Demand

+ {% if plan == "free" %} +

Showing last 5 years. Upgrade for full 18+ year history.

+ {% endif %} + +
+ + +
+

Stock-to-Use Ratio Trend

+ +
+ + +
+
+

Top Producing Countries

+ +
+ +
+

Year-over-Year Production Change

+
+ + + + + + + + + + {% for row in yoy %} + + + + + + {% endfor %} + +
CountryProductionYoY %
{{ row.country_name }}{{ "{:,.0f}".format(row.Production) }} + {% if row.Production_YoY_pct is not none %} + {{ "{:+.1f}%".format(row.Production_YoY_pct) }} + {% else %} + -- + {% endif %} +
+
+
+
+ + + {% if plan != "free" %} +
+ Export CSV +
+ {% else %} +
+

CSV export available on Starter and Pro plans. Upgrade

+
+ {% endif %} + + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/src/beanflows/dashboard/templates/settings.html b/web/src/beanflows/dashboard/templates/settings.html new file mode 100644 index 0000000..04ff271 --- /dev/null +++ b/web/src/beanflows/dashboard/templates/settings.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} + +{% block title %}Settings - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Settings

+
+ + +
+

Profile

+
+
+ + + + + + + +
+
+
+ + +
+

Subscription

+
+
+
+ Current Plan: {{ (user.plan or 'free') | title }} +
+
+ Status: {{ (user.sub_status or 'active') | title }} +
+ {% if user.current_period_end %} +
+ Renews: {{ user.current_period_end[:10] }} +
+ {% endif %} +
+ +
+ {% if subscription %} +
+ + +
+ {% else %} + Upgrade Plan + {% endif %} +
+
+
+ + +
+

API Keys

+
+

API keys allow you to access the API programmatically.

+ + {% if api_keys %} + + + + + + + + + + + + {% for key in api_keys %} + + + + + + + + {% endfor %} + +
NameKeyScopesCreated
{{ key.name }}{{ key.key_prefix }}...{{ key.scopes }}{{ key.created_at[:10] }} +
+ + +
+
+ {% else %} +

No API keys yet.

+ {% endif %} + +
+ Create New API Key +
+ + + + +
+ Scopes + + +
+ + +
+
+
+
+ + +
+

Danger Zone

+
+

Once you delete your account, there is no going back. Please be certain.

+ +
+ Delete Account +

Are you sure? This will:

+
    +
  • Delete all your data
  • +
  • Cancel your subscription
  • +
  • Remove your API keys
  • +
+
+ + +
+
+
+
+
+{% endblock %} diff --git a/web/src/beanflows/migrations/migrate.py b/web/src/beanflows/migrations/migrate.py new file mode 100644 index 0000000..05aee3d --- /dev/null +++ b/web/src/beanflows/migrations/migrate.py @@ -0,0 +1,53 @@ +""" +Simple migration runner. Runs schema.sql against the database. +""" +import sqlite3 +from pathlib import Path +import os +import sys + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from dotenv import load_dotenv + +load_dotenv() + + +def migrate(): + """Run migrations.""" + # Get database path from env or default + db_path = os.getenv("DATABASE_PATH", "data/app.db") + + # Ensure directory exists + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + # Read schema + schema_path = Path(__file__).parent / "schema.sql" + schema = schema_path.read_text() + + # Connect and execute + conn = sqlite3.connect(db_path) + + # Enable WAL mode + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + + # Run schema + conn.executescript(schema) + conn.commit() + + print(f"✓ Migrations complete: {db_path}") + + # Show tables + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = [row[0] for row in cursor.fetchall()] + print(f" Tables: {', '.join(tables)}") + + conn.close() + + +if __name__ == "__main__": + migrate() diff --git a/web/src/beanflows/migrations/schema.sql b/web/src/beanflows/migrations/schema.sql new file mode 100644 index 0000000..2305e2c --- /dev/null +++ b/web/src/beanflows/migrations/schema.sql @@ -0,0 +1,101 @@ +-- BeanFlows Database Schema +-- Run with: python -m beanflows.migrations.migrate + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT, + last_login_at TEXT, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at); + +-- Auth Tokens (magic links) +CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token); +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); + +-- Subscriptions +CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + status TEXT NOT NULL DEFAULT 'free', + + paddle_customer_id TEXT, + paddle_subscription_id TEXT, + + current_period_end TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id); + +CREATE INDEX IF NOT EXISTS idx_subscriptions_provider ON subscriptions(paddle_subscription_id); + + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + key_hash TEXT UNIQUE NOT NULL, + key_prefix TEXT NOT NULL, + scopes TEXT DEFAULT 'read', + created_at TEXT NOT NULL, + last_used_at TEXT, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id); + +-- API Request Log +CREATE TABLE IF NOT EXISTS api_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_requests_user ON api_requests(user_id); +CREATE INDEX IF NOT EXISTS idx_api_requests_date ON api_requests(created_at); + +-- Rate Limits +CREATE TABLE IF NOT EXISTS rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL, + timestamp TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, timestamp); + +-- Background Tasks +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_name TEXT NOT NULL, + payload TEXT, + status TEXT NOT NULL DEFAULT 'pending', + run_at TEXT NOT NULL, + retries INTEGER DEFAULT 0, + error TEXT, + created_at TEXT NOT NULL, + completed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at); diff --git a/web/src/beanflows/public/routes.py b/web/src/beanflows/public/routes.py new file mode 100644 index 0000000..bb014c7 --- /dev/null +++ b/web/src/beanflows/public/routes.py @@ -0,0 +1,45 @@ +""" +Public domain: landing page, marketing pages, legal pages. +""" +from pathlib import Path + +from quart import Blueprint, render_template + +from ..core import config + +# Blueprint with its own template folder +bp = Blueprint( + "public", + __name__, + template_folder=str(Path(__file__).parent / "templates"), +) + + +@bp.route("/") +async def landing(): + """Landing page.""" + return await render_template("landing.html") + + +@bp.route("/features") +async def features(): + """Features page.""" + return await render_template("features.html") + + +@bp.route("/terms") +async def terms(): + """Terms of service.""" + return await render_template("terms.html") + + +@bp.route("/privacy") +async def privacy(): + """Privacy policy.""" + return await render_template("privacy.html") + + +@bp.route("/about") +async def about(): + """About page.""" + return await render_template("about.html") diff --git a/web/src/beanflows/public/templates/about.html b/web/src/beanflows/public/templates/about.html new file mode 100644 index 0000000..7b07173 --- /dev/null +++ b/web/src/beanflows/public/templates/about.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}About - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

About {{ config.APP_NAME }}

+
+ +
+

{{ config.APP_NAME }} was built with a simple philosophy: ship fast, stay simple.

+ +

Too many SaaS boilerplates are over-engineered. They use PostgreSQL when SQLite would do. They add Redis for a job queue that runs 10 tasks a day. They have 50 npm dependencies for a landing page.

+ +

We took a different approach:

+ +
    +
  • SQLite for everything – It handles more than you think.
  • +
  • Server-rendered HTML – No build step, no hydration, no complexity.
  • +
  • Minimal dependencies – Fewer things to break.
  • +
  • Flat structure – Find things where you expect them.
  • +
+ +

The result is a codebase you can understand in an afternoon and deploy for $5/month.

+
+ +
+ Get Started +
+
+
+{% endblock %} diff --git a/web/src/beanflows/public/templates/features.html b/web/src/beanflows/public/templates/features.html new file mode 100644 index 0000000..aaee6f1 --- /dev/null +++ b/web/src/beanflows/public/templates/features.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Features - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Features

+

Coffee market intelligence built on USDA Production, Supply & Distribution data.

+
+ +
+
+

Supply & Demand Dashboard

+

Interactive charts showing global coffee production, exports, imports, ending stocks, and total distribution by market year. Spot surplus and deficit years at a glance.

+
    +
  • 18+ years of historical data (2006–present)
  • +
  • Line charts for production, trade, and consumption trends
  • +
  • Key metric cards for quick orientation
  • +
  • Auto-refreshed daily from USDA PSD Online
  • +
+
+ +
+

Country Analysis & Comparison

+

Rank the world's coffee producers and consumers. Compare up to 10 countries side-by-side on any metric.

+
    +
  • Top-N country rankings (production, exports, imports, stocks)
  • +
  • Year-over-year production change table with directional coloring
  • +
  • Multi-country overlay charts
  • +
  • 65 commodity-country combinations from USDA data
  • +
+
+ +
+

Stock-to-Use Ratio

+

The ratio traders watch most closely. Track the global coffee stock-to-use ratio over time to gauge market tightness and anticipate price moves.

+
    +
  • Global ratio trend chart
  • +
  • Ending stocks vs. total distribution breakdown
  • +
  • Historical context spanning two decades
  • +
+
+ +
+

Data Export & API

+

Download CSV files or integrate directly with your trading systems via REST API.

+
    +
  • CSV export of any metric series
  • +
  • RESTful JSON API with Bearer token auth
  • +
  • Rate-limited and logged for security
  • +
  • Commodity listing, time series, and country endpoints
  • +
+
+ +
+

Daily Data Pipeline

+

Our pipeline extracts data from the USDA PSD Online database, transforms it through a 4-layer SQL pipeline (raw → staging → cleaned → serving), and delivers analytics-ready metrics every day.

+
    +
  • Automated daily extraction from USDA
  • +
  • SQLMesh + DuckDB transformation pipeline
  • +
  • Incremental processing (only new data each day)
  • +
  • Auditable data lineage
  • +
+
+
+ +
+ Start Free + View Pricing +
+
+{% endblock %} diff --git a/web/src/beanflows/public/templates/landing.html b/web/src/beanflows/public/templates/landing.html new file mode 100644 index 0000000..64ba455 --- /dev/null +++ b/web/src/beanflows/public/templates/landing.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block title %}{{ config.APP_NAME }} - Coffee Market Intelligence for Independent Traders{% endblock %} + +{% block content %} +
+ +
+

Coffee Market Intelligence
for Independent Traders

+

+ Track global supply and demand, compare producing countries, and spot trends + with 18+ years of USDA data. No expensive terminal required. +

+ +
+ + +
+

What You Get

+ +
+
+

Supply & Demand Charts

+

Global production, exports, imports, ending stocks, and consumption visualized by market year.

+
+ +
+

Country Analysis

+

Compare up to 10 producing countries side-by-side. See who's growing, who's shrinking.

+
+ +
+

Stock-to-Use Ratio

+

The key indicator traders watch. Track the global ratio over time to gauge tightness.

+
+
+ +
+
+

CSV & API Export

+

Download data for your own models. Integrate with your trading tools via REST API.

+
+ +
+

Daily Refresh

+

Data pipeline runs daily against USDA PSD Online. Always current, always reliable.

+
+ +
+

No Lock-in

+

Public USDA data, open methodology. You own your exports. Cancel anytime.

+
+
+
+ + +
+

How It Works

+ +
+
+

1

+

Sign Up

+

Enter your email, click the magic link. No password needed.

+
+ +
+

2

+

Explore the Dashboard

+

Instant access to coffee supply/demand charts and country rankings.

+
+ +
+

3

+

Go Deeper

+

Upgrade for full history, CSV exports, and API access for your own models.

+
+
+
+ + +
+

Ready to See the Data?

+

Free plan includes the last 5 years of coffee market data. No credit card required.

+ Start Free +
+
+{% endblock %} diff --git a/web/src/beanflows/public/templates/privacy.html b/web/src/beanflows/public/templates/privacy.html new file mode 100644 index 0000000..f76cf09 --- /dev/null +++ b/web/src/beanflows/public/templates/privacy.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Privacy Policy

+

Last updated: January 2024

+
+ +
+

1. Information We Collect

+

We collect information you provide directly:

+
    +
  • Email address (required for account creation)
  • +
  • Name (optional)
  • +
  • Payment information (processed by Stripe)
  • +
+

We automatically collect:

+
    +
  • IP address
  • +
  • Browser type
  • +
  • Usage data
  • +
+
+ +
+

2. How We Use Information

+

We use your information to:

+
    +
  • Provide and maintain the service
  • +
  • Process payments
  • +
  • Send transactional emails
  • +
  • Improve the service
  • +
  • Respond to support requests
  • +
+
+ +
+

3. Information Sharing

+

We do not sell your personal information. We may share information with:

+
    +
  • Service providers (Stripe for payments, Resend for email)
  • +
  • Law enforcement when required by law
  • +
+
+ +
+

4. Data Retention

+

We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.

+
+ +
+

5. Security

+

We implement industry-standard security measures including encryption, secure sessions, and regular backups.

+
+ +
+

6. Cookies

+

We use essential cookies for session management. We do not use tracking or advertising cookies.

+
+ +
+

7. Your Rights

+

You have the right to:

+
    +
  • Access your data
  • +
  • Correct inaccurate data
  • +
  • Delete your account and data
  • +
  • Export your data
  • +
+
+ +
+

8. GDPR Compliance

+

For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.

+
+ +
+

9. Changes

+

We may update this policy. We will notify you of significant changes via email.

+
+ +
+

10. Contact

+

For privacy inquiries: {{ config.EMAIL_FROM }}

+
+
+
+{% endblock %} diff --git a/web/src/beanflows/public/templates/terms.html b/web/src/beanflows/public/templates/terms.html new file mode 100644 index 0000000..577a5aa --- /dev/null +++ b/web/src/beanflows/public/templates/terms.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Terms of Service

+

Last updated: January 2024

+
+ +
+

1. Acceptance of Terms

+

By accessing or using {{ config.APP_NAME }}, you agree to be bound by these Terms of Service. If you do not agree, do not use the service.

+
+ +
+

2. Description of Service

+

{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.

+
+ +
+

3. User Accounts

+

You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.

+
+ +
+

4. Acceptable Use

+

You agree not to:

+
    +
  • Violate any laws or regulations
  • +
  • Infringe on intellectual property rights
  • +
  • Transmit harmful code or malware
  • +
  • Attempt to gain unauthorized access
  • +
  • Interfere with service operation
  • +
+
+ +
+

5. Payment Terms

+

Paid plans are billed in advance. Refunds are handled on a case-by-case basis. We may change pricing with 30 days notice.

+
+ +
+

6. Termination

+

We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.

+
+ +
+

7. Disclaimer of Warranties

+

The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.

+
+ +
+

8. Limitation of Liability

+

We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.

+
+ +
+

9. Changes to Terms

+

We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.

+
+ +
+

10. Contact

+

For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.

+
+
+
+{% endblock %} diff --git a/web/src/beanflows/static/css/custom.css b/web/src/beanflows/static/css/custom.css new file mode 100644 index 0000000..03ae1db --- /dev/null +++ b/web/src/beanflows/static/css/custom.css @@ -0,0 +1,40 @@ +/* BeanFlows Custom Styles */ + +article { + margin-bottom: 1.5rem; +} + +code { + background: var(--code-background-color); + padding: 0.125rem 0.25rem; + border-radius: var(--border-radius); +} + +table { + width: 100%; +} + +/* HTMX loading indicators */ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline; +} + +.htmx-request.htmx-indicator { + display: inline; +} + +/* Dashboard chart sections */ +section canvas { + width: 100% !important; +} + +/* Key metric cards */ +article header small { + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-color); +} diff --git a/web/src/beanflows/templates/base.html b/web/src/beanflows/templates/base.html new file mode 100644 index 0000000..663bd9a --- /dev/null +++ b/web/src/beanflows/templates/base.html @@ -0,0 +1,97 @@ + + + + + + {% block title %}{{ config.APP_NAME }}{% endblock %} + + + + + + + + {% block head %}{% endblock %} + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} + + +
+
+
+ {{ config.APP_NAME }} +

Coffee market intelligence for independent traders.

+
+
+ Product + +
+
+ Legal + +
+
+

+ © {{ now.year }} {{ config.APP_NAME }}. All rights reserved. +

+
+ + + + + {% block scripts %}{% endblock %} + + diff --git a/web/src/beanflows/worker.py b/web/src/beanflows/worker.py new file mode 100644 index 0000000..ca42a93 --- /dev/null +++ b/web/src/beanflows/worker.py @@ -0,0 +1,238 @@ +""" +Background task worker - SQLite-based queue (no Redis needed). +""" +import asyncio +import json +import traceback +from datetime import datetime, timedelta + +from .core import config, init_db, fetch_one, fetch_all, execute, send_email + + +# Task handlers registry +HANDLERS: dict[str, callable] = {} + + +def task(name: str): + """Decorator to register a task handler.""" + def decorator(f): + HANDLERS[name] = f + return f + return decorator + + +# ============================================================================= +# Task Queue Operations +# ============================================================================= + +async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int: + """Add a task to the queue.""" + return await execute( + """ + INSERT INTO tasks (task_name, payload, status, run_at, created_at) + VALUES (?, ?, 'pending', ?, ?) + """, + ( + task_name, + json.dumps(payload or {}), + (run_at or datetime.utcnow()).isoformat(), + datetime.utcnow().isoformat(), + ) + ) + + +async def get_pending_tasks(limit: int = 10) -> list[dict]: + """Get pending tasks ready to run.""" + now = datetime.utcnow().isoformat() + return await fetch_all( + """ + SELECT * FROM tasks + WHERE status = 'pending' AND run_at <= ? + ORDER BY run_at ASC + LIMIT ? + """, + (now, limit) + ) + + +async def mark_complete(task_id: int) -> None: + """Mark task as completed.""" + await execute( + "UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?", + (datetime.utcnow().isoformat(), task_id) + ) + + +async def mark_failed(task_id: int, error: str, retries: int) -> None: + """Mark task as failed, schedule retry if attempts remain.""" + max_retries = 3 + + if retries < max_retries: + # Exponential backoff: 1min, 5min, 25min + delay = timedelta(minutes=5 ** retries) + run_at = datetime.utcnow() + delay + + await execute( + """ + UPDATE tasks + SET status = 'pending', error = ?, retries = ?, run_at = ? + WHERE id = ? + """, + (error, retries + 1, run_at.isoformat(), task_id) + ) + else: + await execute( + "UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", + (error, task_id) + ) + + +# ============================================================================= +# Built-in Task Handlers +# ============================================================================= + +@task("send_email") +async def handle_send_email(payload: dict) -> None: + """Send an email.""" + await send_email( + to=payload["to"], + subject=payload["subject"], + html=payload["html"], + text=payload.get("text"), + ) + + +@task("send_magic_link") +async def handle_send_magic_link(payload: dict) -> None: + """Send magic link email.""" + link = f"{config.BASE_URL}/auth/verify?token={payload['token']}" + + html = f""" +

Sign in to {config.APP_NAME}

+

Click the link below to sign in:

+

{link}

+

This link expires in {config.MAGIC_LINK_EXPIRY_MINUTES} minutes.

+

If you didn't request this, you can safely ignore this email.

+ """ + + await send_email( + to=payload["email"], + subject=f"Sign in to {config.APP_NAME}", + html=html, + ) + + +@task("send_welcome") +async def handle_send_welcome(payload: dict) -> None: + """Send welcome email to new user.""" + html = f""" +

Welcome to {config.APP_NAME}!

+

Thanks for signing up. We're excited to have you.

+

Go to your dashboard

+ """ + + await send_email( + to=payload["email"], + subject=f"Welcome to {config.APP_NAME}", + html=html, + ) + + +@task("cleanup_expired_tokens") +async def handle_cleanup_tokens(payload: dict) -> None: + """Clean up expired auth tokens.""" + await execute( + "DELETE FROM auth_tokens WHERE expires_at < ?", + (datetime.utcnow().isoformat(),) + ) + + +@task("cleanup_rate_limits") +async def handle_cleanup_rate_limits(payload: dict) -> None: + """Clean up old rate limit entries.""" + cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat() + await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,)) + + +@task("cleanup_old_tasks") +async def handle_cleanup_tasks(payload: dict) -> None: + """Clean up completed/failed tasks older than 7 days.""" + cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat() + await execute( + "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", + (cutoff,) + ) + + +# ============================================================================= +# Worker Loop +# ============================================================================= + +async def process_task(task: dict) -> None: + """Process a single task.""" + task_name = task["task_name"] + task_id = task["id"] + retries = task.get("retries", 0) + + handler = HANDLERS.get(task_name) + if not handler: + await mark_failed(task_id, f"Unknown task: {task_name}", retries) + return + + try: + payload = json.loads(task["payload"]) if task["payload"] else {} + await handler(payload) + await mark_complete(task_id) + print(f"[WORKER] Completed: {task_name} (id={task_id})") + except Exception as e: + error = f"{e}\n{traceback.format_exc()}" + await mark_failed(task_id, error, retries) + print(f"[WORKER] Failed: {task_name} (id={task_id}): {e}") + + +async def run_worker(poll_interval: float = 1.0) -> None: + """Main worker loop.""" + print("[WORKER] Starting...") + await init_db() + + while True: + try: + tasks = await get_pending_tasks(limit=10) + + for task in tasks: + await process_task(task) + + if not tasks: + await asyncio.sleep(poll_interval) + + except Exception as e: + print(f"[WORKER] Error: {e}") + await asyncio.sleep(poll_interval * 5) + + +async def run_scheduler() -> None: + """Schedule periodic cleanup tasks.""" + print("[SCHEDULER] Starting...") + await init_db() + + while True: + try: + # Schedule cleanup tasks every hour + await enqueue("cleanup_expired_tokens") + await enqueue("cleanup_rate_limits") + await enqueue("cleanup_old_tasks") + + await asyncio.sleep(3600) # 1 hour + + except Exception as e: + print(f"[SCHEDULER] Error: {e}") + await asyncio.sleep(60) + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "scheduler": + asyncio.run(run_scheduler()) + else: + asyncio.run(run_worker()) diff --git a/web/tests/conftest.py b/web/tests/conftest.py new file mode 100644 index 0000000..37a7f8c --- /dev/null +++ b/web/tests/conftest.py @@ -0,0 +1,247 @@ +""" +Shared test fixtures for the BeanFlows test suite. +""" +import hashlib +import hmac +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import aiosqlite +import pytest + +from beanflows import analytics, core +from beanflows.app import create_app + +SCHEMA_PATH = Path(__file__).parent.parent / "src" / "beanflows" / "migrations" / "schema.sql" + + +# ── Database ───────────────────────────────────────────────── + +@pytest.fixture +async def db(): + """In-memory SQLite with full schema, patches core._db.""" + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA foreign_keys=ON") + + schema = SCHEMA_PATH.read_text() + await conn.executescript(schema) + await conn.commit() + + original_db = core._db + core._db = conn + + yield conn + + core._db = original_db + await conn.close() + + +# ── App & client ───────────────────────────────────────────── + +@pytest.fixture +async def app(db): + """Quart app with DB already initialized (init_db/close_db patched to no-op).""" + with patch.object(core, "init_db", new_callable=AsyncMock), \ + patch.object(core, "close_db", new_callable=AsyncMock), \ + patch.object(analytics, "open_analytics_db"), \ + patch.object(analytics, "close_analytics_db"): + application = create_app() + application.config["TESTING"] = True + yield application + + +@pytest.fixture +async def client(app): + """Unauthenticated test client.""" + async with app.test_client() as c: + yield c + + +# ── Users ──────────────────────────────────────────────────── + +@pytest.fixture +async def test_user(db): + """Create a test user, return dict with id/email/name.""" + now = datetime.utcnow().isoformat() + async with db.execute( + "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", + ("test@example.com", "Test User", now), + ) as cursor: + user_id = cursor.lastrowid + await db.commit() + return {"id": user_id, "email": "test@example.com", "name": "Test User"} + + +@pytest.fixture +async def auth_client(app, test_user): + """Test client with session['user_id'] pre-set.""" + async with app.test_client() as c: + async with c.session_transaction() as sess: + sess["user_id"] = test_user["id"] + yield c + + +# ── Subscriptions ──────────────────────────────────────────── + +@pytest.fixture +def create_subscription(db): + """Factory: create a subscription row for a user.""" + async def _create( + user_id: int, + plan: str = "pro", + status: str = "active", + + paddle_customer_id: str = "ctm_test123", + paddle_subscription_id: str = "sub_test456", + + current_period_end: str = "2025-03-01T00:00:00Z", + ) -> int: + now = datetime.utcnow().isoformat() + async with db.execute( + + """INSERT INTO subscriptions + (user_id, plan, status, paddle_customer_id, + paddle_subscription_id, current_period_end, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (user_id, plan, status, paddle_customer_id, paddle_subscription_id, + current_period_end, now, now), + + ) as cursor: + sub_id = cursor.lastrowid + await db.commit() + return sub_id + return _create + + +# ── Config ─────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def patch_config(): + + """Set test Paddle config values.""" + + original_values = {} + test_values = { + + "PADDLE_API_KEY": "test_api_key_123", + "PADDLE_WEBHOOK_SECRET": "whsec_test_secret", + "PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"}, + + "BASE_URL": "http://localhost:5000", + "DEBUG": True, + } + for key, val in test_values.items(): + original_values[key] = getattr(core.config, key, None) + setattr(core.config, key, val) + + + yield + + + for key, val in original_values.items(): + setattr(core.config, key, val) + + +# ── Webhook helpers ────────────────────────────────────────── + + +def make_webhook_payload( + event_type: str, + subscription_id: str = "sub_test456", + customer_id: str = "ctm_test123", + user_id: str = "1", + plan: str = "starter", + status: str = "active", + ends_at: str = "2025-03-01T00:00:00.000000Z", +) -> dict: + """Build a Paddle webhook payload dict.""" + return { + "event_type": event_type, + "data": { + "id": subscription_id, + "status": status, + "customer_id": customer_id, + "custom_data": {"user_id": user_id, "plan": plan}, + "current_billing_period": { + "starts_at": "2025-02-01T00:00:00.000000Z", + "ends_at": ends_at, + }, + }, + } + + +def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str: + """Compute HMAC-SHA256 signature for a webhook payload.""" + return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest() + + +# ── Analytics mock data ────────────────────────────────────── + +MOCK_TIME_SERIES = [ + {"market_year": 2018, "Production": 165000, "Exports": 115000, "Imports": 105000, + "Ending_Stocks": 33000, "Total_Distribution": 160000}, + {"market_year": 2019, "Production": 168000, "Exports": 118000, "Imports": 108000, + "Ending_Stocks": 34000, "Total_Distribution": 163000}, + {"market_year": 2020, "Production": 170000, "Exports": 120000, "Imports": 110000, + "Ending_Stocks": 35000, "Total_Distribution": 165000}, + {"market_year": 2021, "Production": 175000, "Exports": 125000, "Imports": 115000, + "Ending_Stocks": 36000, "Total_Distribution": 170000}, + {"market_year": 2022, "Production": 172000, "Exports": 122000, "Imports": 112000, + "Ending_Stocks": 34000, "Total_Distribution": 168000}, +] + +MOCK_TOP_COUNTRIES = [ + {"country_name": "Brazil", "country_code": "BR", "market_year": 2022, "Production": 65000}, + {"country_name": "Vietnam", "country_code": "VN", "market_year": 2022, "Production": 30000}, + {"country_name": "Colombia", "country_code": "CO", "market_year": 2022, "Production": 14000}, +] + +MOCK_STU_TREND = [ + {"market_year": 2020, "Stock_to_Use_Ratio_pct": 21.2}, + {"market_year": 2021, "Stock_to_Use_Ratio_pct": 21.1}, + {"market_year": 2022, "Stock_to_Use_Ratio_pct": 20.2}, +] + +MOCK_BALANCE = [ + {"market_year": 2020, "Production": 170000, "Total_Distribution": 165000, "Supply_Demand_Balance": 5000}, + {"market_year": 2021, "Production": 175000, "Total_Distribution": 170000, "Supply_Demand_Balance": 5000}, + {"market_year": 2022, "Production": 172000, "Total_Distribution": 168000, "Supply_Demand_Balance": 4000}, +] + +MOCK_YOY = [ + {"country_name": "Brazil", "country_code": "BR", "market_year": 2022, + "Production": 65000, "Production_YoY_pct": -3.5}, + {"country_name": "Vietnam", "country_code": "VN", "market_year": 2022, + "Production": 30000, "Production_YoY_pct": 2.1}, +] + +MOCK_COMMODITIES = [ + {"commodity_code": 711100, "commodity_name": "Coffee, Green"}, + {"commodity_code": 222000, "commodity_name": "Soybeans"}, +] + + +@pytest.fixture +def mock_analytics(): + """Patch all analytics query functions with mock data.""" + with patch.object(analytics, "get_global_time_series", new_callable=AsyncMock, + return_value=MOCK_TIME_SERIES), \ + patch.object(analytics, "get_top_countries", new_callable=AsyncMock, + return_value=MOCK_TOP_COUNTRIES), \ + patch.object(analytics, "get_stock_to_use_trend", new_callable=AsyncMock, + return_value=MOCK_STU_TREND), \ + patch.object(analytics, "get_supply_demand_balance", new_callable=AsyncMock, + return_value=MOCK_BALANCE), \ + patch.object(analytics, "get_production_yoy_by_country", new_callable=AsyncMock, + return_value=MOCK_YOY), \ + patch.object(analytics, "get_country_comparison", new_callable=AsyncMock, + return_value=[]), \ + patch.object(analytics, "get_available_commodities", new_callable=AsyncMock, + return_value=MOCK_COMMODITIES), \ + patch.object(analytics, "fetch_analytics", new_callable=AsyncMock, + return_value=[{"result": 1}]): + yield + + diff --git a/web/tests/test_api_commodities.py b/web/tests/test_api_commodities.py new file mode 100644 index 0000000..2bec82c --- /dev/null +++ b/web/tests/test_api_commodities.py @@ -0,0 +1,129 @@ +""" +Tests for the commodity analytics API endpoints. +""" +import hashlib +import secrets +from datetime import datetime + +import pytest + + +async def _create_api_key_for_user(db, user_id, plan="starter"): + """Helper: create an API key and subscription, return the raw key.""" + raw_key = f"sk_{secrets.token_urlsafe(32)}" + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + now = datetime.utcnow().isoformat() + + await db.execute( + """INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (user_id, "test-key", key_hash, raw_key[:12], "read,write", now), + ) + + # Create subscription for plan + if plan != "free": + await db.execute( + """INSERT OR REPLACE INTO subscriptions + (user_id, plan, status, created_at, updated_at) + VALUES (?, ?, 'active', ?, ?)""", + (user_id, plan, now, now), + ) + await db.commit() + + return raw_key + + +@pytest.mark.asyncio +async def test_api_requires_auth(client): + """API returns 401 without auth header.""" + response = await client.get("/api/v1/commodities") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_api_rejects_free_plan(client, db, test_user, mock_analytics): + """API returns 403 for free plan users.""" + raw_key = await _create_api_key_for_user(db, test_user["id"], plan="free") + response = await client.get( + "/api/v1/commodities", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_list_commodities(client, db, test_user, mock_analytics): + """GET /commodities returns commodity list.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/commodities", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert "commodities" in data + assert len(data["commodities"]) == 2 + + +@pytest.mark.asyncio +async def test_commodity_metrics(client, db, test_user, mock_analytics): + """GET /commodities//metrics returns time series.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/commodities/711100/metrics?metrics=Production&metrics=Exports", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data["commodity_code"] == 711100 + assert "Production" in data["metrics"] + + +@pytest.mark.asyncio +async def test_commodity_metrics_invalid_metric(client, db, test_user, mock_analytics): + """GET /commodities//metrics rejects invalid metrics.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/commodities/711100/metrics?metrics=DROP_TABLE", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_commodity_countries(client, db, test_user, mock_analytics): + """GET /commodities//countries returns ranking.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/commodities/711100/countries?metric=Production&limit=5", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data["metric"] == "Production" + + +@pytest.mark.asyncio +async def test_commodity_csv_export(client, db, test_user, mock_analytics): + """GET /commodities//metrics.csv returns CSV.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/commodities/711100/metrics.csv", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 200 + assert "text/csv" in response.content_type + + +@pytest.mark.asyncio +async def test_me_endpoint(client, db, test_user, mock_analytics): + """GET /me returns user info.""" + raw_key = await _create_api_key_for_user(db, test_user["id"]) + response = await client.get( + "/api/v1/me", + headers={"Authorization": f"Bearer {raw_key}"}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data["email"] == "test@example.com" + assert data["plan"] == "starter" diff --git a/web/tests/test_billing_helpers.py b/web/tests/test_billing_helpers.py new file mode 100644 index 0000000..8117396 --- /dev/null +++ b/web/tests/test_billing_helpers.py @@ -0,0 +1,325 @@ +""" +Unit tests for billing SQL helpers, feature/limit access, and plan determination. +""" +import pytest +from hypothesis import HealthCheck, given +from hypothesis import settings as h_settings +from hypothesis import strategies as st + +from beanflows.billing.routes import ( + + can_access_feature, + get_subscription, + get_subscription_by_provider_id, + is_within_limits, + update_subscription_status, + upsert_subscription, +) +from beanflows.core import config + +# ════════════════════════════════════════════════════════════ +# get_subscription +# ════════════════════════════════════════════════════════════ + +class TestGetSubscription: + async def test_returns_none_for_user_without_subscription(self, db, test_user): + result = await get_subscription(test_user["id"]) + assert result is None + + async def test_returns_subscription_for_user(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + result = await get_subscription(test_user["id"]) + assert result is not None + assert result["plan"] == "pro" + assert result["status"] == "active" + assert result["user_id"] == test_user["id"] + + +# ════════════════════════════════════════════════════════════ +# upsert_subscription +# ════════════════════════════════════════════════════════════ + +class TestUpsertSubscription: + async def test_insert_new_subscription(self, db, test_user): + sub_id = await upsert_subscription( + user_id=test_user["id"], + plan="pro", + status="active", + provider_customer_id="cust_abc", + provider_subscription_id="sub_xyz", + current_period_end="2025-06-01T00:00:00Z", + ) + assert sub_id > 0 + row = await get_subscription(test_user["id"]) + assert row["plan"] == "pro" + assert row["status"] == "active" + + assert row["paddle_customer_id"] == "cust_abc" + assert row["paddle_subscription_id"] == "sub_xyz" + + assert row["current_period_end"] == "2025-06-01T00:00:00Z" + + async def test_update_existing_subscription(self, db, test_user, create_subscription): + original_id = await create_subscription( + test_user["id"], plan="starter", status="active", + + paddle_subscription_id="sub_old", + + ) + returned_id = await upsert_subscription( + user_id=test_user["id"], + plan="pro", + status="active", + provider_customer_id="cust_new", + provider_subscription_id="sub_new", + ) + assert returned_id == original_id + row = await get_subscription(test_user["id"]) + assert row["plan"] == "pro" + + assert row["paddle_subscription_id"] == "sub_new" + + + async def test_upsert_with_none_period_end(self, db, test_user): + await upsert_subscription( + user_id=test_user["id"], + plan="pro", + status="active", + provider_customer_id="cust_1", + provider_subscription_id="sub_1", + current_period_end=None, + ) + row = await get_subscription(test_user["id"]) + assert row["current_period_end"] is None + + +# ════════════════════════════════════════════════════════════ +# get_subscription_by_provider_id +# ════════════════════════════════════════════════════════════ + +class TestGetSubscriptionByProviderId: + async def test_returns_none_for_unknown_id(self, db): + result = await get_subscription_by_provider_id("nonexistent") + assert result is None + + + async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], paddle_subscription_id="sub_findme") + + result = await get_subscription_by_provider_id("sub_findme") + assert result is not None + assert result["user_id"] == test_user["id"] + + +# ════════════════════════════════════════════════════════════ +# update_subscription_status +# ════════════════════════════════════════════════════════════ + +class TestUpdateSubscriptionStatus: + async def test_updates_status(self, db, test_user, create_subscription): + + await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd") + + await update_subscription_status("sub_upd", status="cancelled") + row = await get_subscription(test_user["id"]) + assert row["status"] == "cancelled" + assert row["updated_at"] is not None + + async def test_updates_extra_fields(self, db, test_user, create_subscription): + + await create_subscription(test_user["id"], paddle_subscription_id="sub_extra") + + await update_subscription_status( + "sub_extra", + status="active", + plan="starter", + current_period_end="2026-01-01T00:00:00Z", + ) + row = await get_subscription(test_user["id"]) + assert row["status"] == "active" + assert row["plan"] == "starter" + assert row["current_period_end"] == "2026-01-01T00:00:00Z" + + async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription): + + await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active") + + await update_subscription_status("sub_unknown", status="expired") + row = await get_subscription(test_user["id"]) + assert row["status"] == "active" # unchanged + + +# ════════════════════════════════════════════════════════════ +# can_access_feature +# ════════════════════════════════════════════════════════════ + +class TestCanAccessFeature: + async def test_no_subscription_gets_free_features(self, db, test_user): + assert await can_access_feature(test_user["id"], "dashboard") is True + assert await can_access_feature(test_user["id"], "export") is False + assert await can_access_feature(test_user["id"], "api") is False + + async def test_active_pro_gets_all_features(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + assert await can_access_feature(test_user["id"], "dashboard") is True + assert await can_access_feature(test_user["id"], "export") is True + assert await can_access_feature(test_user["id"], "api") is True + assert await can_access_feature(test_user["id"], "priority_support") is True + + async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="starter", status="active") + assert await can_access_feature(test_user["id"], "dashboard") is True + assert await can_access_feature(test_user["id"], "export") is True + assert await can_access_feature(test_user["id"], "all_commodities") is False + + async def test_cancelled_still_has_features(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="cancelled") + assert await can_access_feature(test_user["id"], "api") is True + + async def test_on_trial_has_features(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="on_trial") + assert await can_access_feature(test_user["id"], "api") is True + + async def test_expired_falls_back_to_free(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="expired") + assert await can_access_feature(test_user["id"], "api") is False + assert await can_access_feature(test_user["id"], "dashboard") is True + + async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="past_due") + assert await can_access_feature(test_user["id"], "export") is False + + async def test_paused_falls_back_to_free(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="paused") + assert await can_access_feature(test_user["id"], "api") is False + + async def test_nonexistent_feature_returns_false(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + assert await can_access_feature(test_user["id"], "teleportation") is False + + +# ════════════════════════════════════════════════════════════ +# is_within_limits +# ════════════════════════════════════════════════════════════ + +class TestIsWithinLimits: + async def test_free_user_no_api_calls(self, db, test_user): + assert await is_within_limits(test_user["id"], "api_calls", 0) is False + + async def test_free_user_commodity_limit(self, db, test_user): + assert await is_within_limits(test_user["id"], "commodities", 0) is True + assert await is_within_limits(test_user["id"], "commodities", 1) is False + + async def test_free_user_history_limit(self, db, test_user): + assert await is_within_limits(test_user["id"], "history_years", 4) is True + assert await is_within_limits(test_user["id"], "history_years", 5) is False + + async def test_pro_unlimited(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + assert await is_within_limits(test_user["id"], "commodities", 999999) is True + assert await is_within_limits(test_user["id"], "api_calls", 999999) is True + + async def test_starter_limits(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="starter", status="active") + assert await is_within_limits(test_user["id"], "api_calls", 9999) is True + assert await is_within_limits(test_user["id"], "api_calls", 10000) is False + + async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="expired") + assert await is_within_limits(test_user["id"], "api_calls", 0) is False + + async def test_unknown_resource_returns_false(self, db, test_user): + assert await is_within_limits(test_user["id"], "unicorns", 0) is False + + + +# ════════════════════════════════════════════════════════════ +# Parameterized: status × feature access matrix +# ════════════════════════════════════════════════════════════ + +STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"] +FEATURES = ["dashboard", "export", "api", "priority_support"] +ACTIVE_STATUSES = {"active", "on_trial", "cancelled"} + + +@pytest.mark.parametrize("status", STATUSES) +@pytest.mark.parametrize("feature", FEATURES) +async def test_feature_access_matrix(db, test_user, create_subscription, status, feature): + if status != "free": + await create_subscription(test_user["id"], plan="pro", status=status) + + result = await can_access_feature(test_user["id"], feature) + + if status in ACTIVE_STATUSES: + expected = feature in config.PLAN_FEATURES["pro"] + else: + expected = feature in config.PLAN_FEATURES["free"] + + assert result == expected, f"status={status}, feature={feature}" + + +# ════════════════════════════════════════════════════════════ +# Parameterized: plan × feature matrix (active status) +# ════════════════════════════════════════════════════════════ + +PLANS = ["free", "starter", "pro"] + + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("feature", FEATURES) +async def test_plan_feature_matrix(db, test_user, create_subscription, plan, feature): + if plan != "free": + await create_subscription(test_user["id"], plan=plan, status="active") + + result = await can_access_feature(test_user["id"], feature) + expected = feature in config.PLAN_FEATURES.get(plan, []) + assert result == expected, f"plan={plan}, feature={feature}" + + +# ════════════════════════════════════════════════════════════ +# Parameterized: plan × resource limit boundaries +# ════════════════════════════════════════════════════════════ + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("resource,at_limit", [ + ("commodities", 1), + ("commodities", 65), + ("api_calls", 0), + ("api_calls", 10000), +]) +async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit): + if plan != "free": + await create_subscription(test_user["id"], plan=plan, status="active") + + plan_limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0) + result = await is_within_limits(test_user["id"], resource, at_limit) + + if plan_limit == -1: + assert result is True + elif at_limit < plan_limit: + assert result is True + else: + assert result is False + + +# ════════════════════════════════════════════════════════════ +# Hypothesis: limit boundaries +# ════════════════════════════════════════════════════════════ + +class TestLimitsHypothesis: + @given(count=st.integers(min_value=0, max_value=100)) + @h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) + async def test_free_limit_boundary_commodities(self, db, test_user, count): + result = await is_within_limits(test_user["id"], "commodities", count) + assert result == (count < 1) + + @given(count=st.integers(min_value=0, max_value=100000)) + @h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture]) + async def test_pro_always_within_limits(self, db, test_user, create_subscription, count): + # Use upsert to avoid duplicate inserts across Hypothesis examples + await upsert_subscription( + user_id=test_user["id"], plan="pro", status="active", + provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp", + ) + result = await is_within_limits(test_user["id"], "commodities", count) + assert result is True diff --git a/web/tests/test_billing_routes.py b/web/tests/test_billing_routes.py new file mode 100644 index 0000000..4830874 --- /dev/null +++ b/web/tests/test_billing_routes.py @@ -0,0 +1,268 @@ +""" +Route integration tests for Paddle billing endpoints. +External Paddle API calls mocked with respx. +""" +import json + +import httpx +import pytest +import respx + + +CHECKOUT_METHOD = "POST" +CHECKOUT_PLAN = "starter" + + + +# ════════════════════════════════════════════════════════════ +# Public routes (pricing, success) +# ════════════════════════════════════════════════════════════ + +class TestPricingPage: + async def test_accessible_without_auth(self, client, db): + response = await client.get("/billing/pricing") + assert response.status_code == 200 + + async def test_accessible_with_auth(self, auth_client, db, test_user): + response = await auth_client.get("/billing/pricing") + assert response.status_code == 200 + + async def test_with_subscription(self, auth_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + response = await auth_client.get("/billing/pricing") + assert response.status_code == 200 + + +class TestSuccessPage: + async def test_requires_auth(self, client, db): + response = await client.get("/billing/success", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + async def test_accessible_with_auth(self, auth_client, db, test_user): + response = await auth_client.get("/billing/success") + assert response.status_code == 200 + + +# ════════════════════════════════════════════════════════════ +# Checkout +# ════════════════════════════════════════════════════════════ + +class TestCheckoutRoute: + async def test_requires_auth(self, client, db): + + response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False) + + assert response.status_code in (302, 303, 307) + + @respx.mock + async def test_creates_checkout_session(self, auth_client, db, test_user): + + respx.post("https://api.paddle.com/transactions").mock( + return_value=httpx.Response(200, json={ + "data": { + "checkout": { + "url": "https://checkout.paddle.com/test_123" + } + } + }) + ) + + + + response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False) + + assert response.status_code in (302, 303, 307) + + async def test_invalid_plan_rejected(self, auth_client, db, test_user): + + response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False) + + assert response.status_code in (302, 303, 307) + + + + + @respx.mock + async def test_api_error_propagates(self, auth_client, db, test_user): + + respx.post("https://api.paddle.com/transactions").mock( + return_value=httpx.Response(500, json={"error": "server error"}) + ) + + with pytest.raises(httpx.HTTPStatusError): + + await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}") + + + + +# ════════════════════════════════════════════════════════════ +# Manage subscription / Portal +# ════════════════════════════════════════════════════════════ + + +class TestManageRoute: + async def test_requires_auth(self, client, db): + response = await client.post("/billing/manage", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + async def test_requires_subscription(self, auth_client, db, test_user): + response = await auth_client.post("/billing/manage", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + @respx.mock + async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription): + + await create_subscription(test_user["id"], paddle_subscription_id="sub_test") + + respx.get("https://api.paddle.com/subscriptions/sub_test").mock( + return_value=httpx.Response(200, json={ + "data": { + "management_urls": { + "update_payment_method": "https://paddle.com/manage/test_123" + } + } + }) + ) + + + response = await auth_client.post("/billing/manage", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + + +# ════════════════════════════════════════════════════════════ +# Cancel subscription +# ════════════════════════════════════════════════════════════ + + +class TestCancelRoute: + async def test_requires_auth(self, client, db): + response = await client.post("/billing/cancel", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + async def test_no_error_without_subscription(self, auth_client, db, test_user): + response = await auth_client.post("/billing/cancel", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + @respx.mock + async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription): + + await create_subscription(test_user["id"], paddle_subscription_id="sub_test") + + respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock( + return_value=httpx.Response(200, json={"data": {}}) + ) + + + response = await auth_client.post("/billing/cancel", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + + + + + +# ════════════════════════════════════════════════════════════ +# subscription_required decorator +# ════════════════════════════════════════════════════════════ + +from beanflows.billing.routes import subscription_required +from quart import Blueprint + +test_bp = Blueprint("test", __name__) + + +@test_bp.route("/protected") +@subscription_required() +async def protected_route(): + return "success", 200 + + +@test_bp.route("/custom_allowed") +@subscription_required(allowed=("active", "past_due")) +async def custom_allowed_route(): + return "success", 200 + + +class TestSubscriptionRequiredDecorator: + @pytest.fixture + async def test_app(self, app): + app.register_blueprint(test_bp) + return app + + @pytest.fixture + async def test_client(self, test_app): + async with test_app.test_client() as c: + yield c + + async def test_redirects_unauthenticated(self, test_client, db): + response = await test_client.get("/protected", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + async def test_redirects_without_subscription(self, test_client, db, test_user): + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="active") + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected") + assert response.status_code == 200 + + async def test_allows_on_trial(self, test_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="on_trial") + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected") + assert response.status_code == 200 + + async def test_allows_cancelled(self, test_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="cancelled") + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected") + assert response.status_code == 200 + + async def test_rejects_expired(self, test_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="expired") + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected", follow_redirects=False) + assert response.status_code in (302, 303, 307) + + @pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]) + async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status): + if status != "free": + await create_subscription(test_user["id"], plan="pro", status=status) + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/protected", follow_redirects=False) + + if status in ("active", "on_trial", "cancelled"): + assert response.status_code == 200 + else: + assert response.status_code in (302, 303, 307) + + async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription): + await create_subscription(test_user["id"], plan="pro", status="past_due") + + async with test_client.session_transaction() as sess: + sess["user_id"] = test_user["id"] + + response = await test_client.get("/custom_allowed") + assert response.status_code == 200 diff --git a/web/tests/test_billing_webhooks.py b/web/tests/test_billing_webhooks.py new file mode 100644 index 0000000..37a3251 --- /dev/null +++ b/web/tests/test_billing_webhooks.py @@ -0,0 +1,274 @@ +""" +Integration tests for Paddle webhook handling. +Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing. +""" +import json + +import pytest +from hypothesis import HealthCheck, given +from hypothesis import settings as h_settings +from hypothesis import strategies as st + +from beanflows.billing.routes import get_subscription + +from conftest import make_webhook_payload, sign_payload + + +WEBHOOK_PATH = "/billing/webhook/paddle" +SIG_HEADER = "Paddle-Signature" + + + +# ════════════════════════════════════════════════════════════ +# Signature Verification +# ════════════════════════════════════════════════════════════ + +class TestWebhookSignature: + async def test_missing_signature_rejected(self, client, db): + + payload = make_webhook_payload("subscription.activated") + + payload_bytes = json.dumps(payload).encode() + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code in (400, 401) + + + async def test_invalid_signature_rejected(self, client, db): + + payload = make_webhook_payload("subscription.activated") + + payload_bytes = json.dumps(payload).encode() + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"}, + ) + + assert response.status_code in (400, 401) + + + async def test_valid_signature_accepted(self, client, db, test_user): + + payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"])) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + + assert response.status_code in (200, 204) + + + async def test_modified_payload_rejected(self, client, db, test_user): + + payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"])) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + tampered = payload_bytes + b"extra" + + # Paddle/LemonSqueezy: HMAC signature verification fails before JSON parsing + response = await client.post( + WEBHOOK_PATH, + data=tampered, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (400, 401) + + + async def test_empty_payload_rejected(self, client, db): + + sig = sign_payload(b"") + + + with pytest.raises(Exception): # JSONDecodeError in TESTING mode + await client.post( + WEBHOOK_PATH, + data=b"", + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + + +# ════════════════════════════════════════════════════════════ +# Subscription Lifecycle Events +# ════════════════════════════════════════════════════════════ + + +class TestWebhookSubscriptionActivated: + async def test_creates_subscription(self, client, db, test_user): + payload = make_webhook_payload( + "subscription.activated", + user_id=str(test_user["id"]), + plan="starter", + ) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (200, 204) + + sub = await get_subscription(test_user["id"]) + assert sub is not None + assert sub["plan"] == "starter" + assert sub["status"] == "active" + + +class TestWebhookSubscriptionUpdated: + async def test_updates_subscription_status(self, client, db, test_user, create_subscription): + await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") + + payload = make_webhook_payload( + "subscription.updated", + subscription_id="sub_test456", + status="paused", + ) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (200, 204) + + sub = await get_subscription(test_user["id"]) + assert sub["status"] == "paused" + + +class TestWebhookSubscriptionCanceled: + async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription): + await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") + + payload = make_webhook_payload( + "subscription.canceled", + subscription_id="sub_test456", + ) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (200, 204) + + sub = await get_subscription(test_user["id"]) + assert sub["status"] == "cancelled" + + +class TestWebhookSubscriptionPastDue: + async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription): + await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456") + + payload = make_webhook_payload( + "subscription.past_due", + subscription_id="sub_test456", + ) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (200, 204) + + sub = await get_subscription(test_user["id"]) + assert sub["status"] == "past_due" + + + + +# ════════════════════════════════════════════════════════════ +# Parameterized: event → status transitions +# ════════════════════════════════════════════════════════════ + + +@pytest.mark.parametrize("event_type,expected_status", [ + ("subscription.activated", "active"), + ("subscription.updated", "active"), + ("subscription.canceled", "cancelled"), + ("subscription.past_due", "past_due"), +]) +async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status): + if event_type != "subscription.activated": + await create_subscription(test_user["id"], paddle_subscription_id="sub_test456") + + payload = make_webhook_payload(event_type, user_id=str(test_user["id"])) + payload_bytes = json.dumps(payload).encode() + sig = sign_payload(payload_bytes) + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code in (200, 204) + + sub = await get_subscription(test_user["id"]) + assert sub["status"] == expected_status + + + + +# ════════════════════════════════════════════════════════════ +# Hypothesis: fuzz webhook payloads +# ════════════════════════════════════════════════════════════ + + +fuzz_event_type = st.sampled_from([ + "subscription.activated", + "subscription.updated", + "subscription.canceled", + "subscription.past_due", +]) +fuzz_status = st.sampled_from(["active", "paused", "past_due", "canceled"]) + + + +@st.composite +def fuzz_payload(draw): + event_type = draw(fuzz_event_type) + return make_webhook_payload( + event_type=event_type, + subscription_id=f"sub_{draw(st.integers(min_value=100, max_value=999999))}", + user_id=str(draw(st.integers(min_value=1, max_value=999999))), + status=draw(fuzz_status), + ) + + + +class TestWebhookHypothesis: + @given(payload_dict=fuzz_payload()) + @h_settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture]) + async def test_webhook_never_500s(self, client, db, test_user, payload_dict): + # Pin user_id to the test user so subscription_created/activated events don't hit FK violations + + payload_dict["data"]["custom_data"]["user_id"] = str(test_user["id"]) + payload_bytes = json.dumps(payload_dict).encode() + sig = sign_payload(payload_bytes) + + + response = await client.post( + WEBHOOK_PATH, + data=payload_bytes, + headers={SIG_HEADER: sig, "Content-Type": "application/json"}, + ) + assert response.status_code < 500 diff --git a/web/tests/test_dashboard.py b/web/tests/test_dashboard.py new file mode 100644 index 0000000..42558b2 --- /dev/null +++ b/web/tests/test_dashboard.py @@ -0,0 +1,79 @@ +""" +Tests for the coffee analytics dashboard. +""" +import pytest + + +@pytest.mark.asyncio +async def test_dashboard_requires_login(client): + """Dashboard redirects unauthenticated users.""" + response = await client.get("/dashboard/") + assert response.status_code == 302 + + +@pytest.mark.asyncio +async def test_dashboard_loads(auth_client, mock_analytics): + """Dashboard renders with chart data for authenticated user.""" + response = await auth_client.get("/dashboard/") + assert response.status_code == 200 + + body = (await response.get_data(as_text=True)) + assert "Coffee Dashboard" in body + assert "Global Supply" in body + assert "Stock-to-Use" in body + assert "Top Producing Countries" in body + + +@pytest.mark.asyncio +async def test_dashboard_shows_metric_cards(auth_client, mock_analytics): + """Dashboard shows key metric values from latest data.""" + response = await auth_client.get("/dashboard/") + body = (await response.get_data(as_text=True)) + + # Latest production from mock: 172,000 + assert "172,000" in body + + +@pytest.mark.asyncio +async def test_dashboard_yoy_table(auth_client, mock_analytics): + """Dashboard renders YoY production change table.""" + response = await auth_client.get("/dashboard/") + body = (await response.get_data(as_text=True)) + + assert "Brazil" in body + assert "Vietnam" in body + + +@pytest.mark.asyncio +async def test_dashboard_free_plan_limits_history(auth_client, mock_analytics): + """Free plan should show limited history notice.""" + response = await auth_client.get("/dashboard/") + body = (await response.get_data(as_text=True)) + + assert "Upgrade" in body + + +@pytest.mark.asyncio +async def test_dashboard_free_plan_no_csv_export(auth_client, mock_analytics): + """Free plan should not show CSV export button.""" + response = await auth_client.get("/dashboard/") + body = (await response.get_data(as_text=True)) + + assert "CSV export available on Starter" in body + + +@pytest.mark.asyncio +async def test_countries_page_loads(auth_client, mock_analytics): + """Country comparison page loads.""" + response = await auth_client.get("/dashboard/countries") + assert response.status_code == 200 + + body = (await response.get_data(as_text=True)) + assert "Country Comparison" in body + + +@pytest.mark.asyncio +async def test_countries_page_with_selection(auth_client, mock_analytics): + """Country comparison with country params.""" + response = await auth_client.get("/dashboard/countries?country=BR&country=VN&metric=Production") + assert response.status_code == 200