Compare commits

..

153 Commits

Author SHA1 Message Date
Deeman
b071199895 fix(docker): copy content/ directory into image
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 2s
content/articles/ holds the cornerstone .md source files which
_sync_static_articles() reads on every /admin/articles load.
Without this COPY they were absent from the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 15:03:44 +01:00
Deeman
af536f22ea refactor: introduce REPO_ROOT in core.py, replace all CWD-relative paths
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 2s
2026-03-07 14:52:38 +01:00
Deeman
c320bef83e refactor: introduce REPO_ROOT in core.py, replace all CWD-relative paths
Defines REPO_ROOT = Path(__file__).parents[3] once in core.py.
Replaces Path(__file__).parent.parent...parent chains and Path("data/...")
CWD-relative references in admin/routes.py, content/__init__.py,
content/routes.py, and worker.py (4x local repo_root variables).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 14:51:34 +01:00
Deeman
2938661ae7 refactor: move article .md sources from data/ to content/articles/
All checks were successful
CI / test (push) Successful in 55s
CI / tag (push) Successful in 3s
data/ is gitignored (pipeline artifacts). Article .md files are source
content and must be version-controlled. Moved to content/articles/ at
repo root. Also updates _ARTICLES_DIR and all Path("data/content/articles")
references in admin/routes.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 14:47:49 +01:00
Deeman
9f8afdbda7 test(admin): regression tests — article delete never removes .md source
Three cases: single delete, bulk by IDs, bulk apply_to_all.
Also extends _create_article() helper with article_type param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 14:10:41 +01:00
Deeman
055cc23482 test(admin): regression tests — article delete never removes .md source
All checks were successful
CI / test (push) Successful in 1m3s
CI / tag (push) Successful in 3s
2026-03-07 14:10:41 +01:00
Deeman
66353b3da1 fix(admin): article delete only removes build file + DB row, never .md source 2026-03-07 13:52:24 +01:00
Deeman
15378b1804 fix(admin): article delete only removes build file + DB row, never .md source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:52:17 +01:00
Deeman
03fdec7297 feat(admin): article type tabs + fix affiliate delete buttons
- Migration 0029: article_type column (cornerstone/editorial/generated)
- Tab bar on /admin/articles with per-type counts
- Template filter only on Generated tab; delete guard uses article_type
- Type dropdown in article_new/edit form
- Fix: affiliate program and product Delete buttons had missing text/tag
2026-03-07 13:50:44 +01:00
Deeman
608f0356a5 fix(admin): affiliate program + product delete buttons missing text/closing tag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:50:35 +01:00
Deeman
39225d6cfd feat(admin): article type tabs (cornerstone / editorial / generated)
- Migration 0029: ADD COLUMN article_type + backfill + index
- Tab bar on /admin/articles with per-type counts
- _build_article_where, _get_article_list, _get_article_list_grouped, and
  all routes now accept and thread article_type filter
- Template dropdown only shown on Generated tab
- Bulk form and matching-count endpoint carry article_type
- Delete guard uses article_type == 'generated' (not template_slug check)
- _sync_static_articles derives article_type from cornerstone frontmatter field
- generate_articles() upserts with article_type = 'generated'
- article_new / article_edit: Type dropdown (Editorial / Cornerstone)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 12:21:07 +01:00
Deeman
e537bfd9d3 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:13:23 +01:00
Deeman
a27da79705 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
- Bulk delete (both explicit-IDs and apply_to_all paths) now only unlinks
  source .md files for generated articles (template_slug IS NOT NULL).
  Manual cornerstone articles keep their .md source on disk.

- _sync_static_articles() now also renders markdown → HTML and writes to
  BUILD_DIR/<lang>/<slug>.html after upserting the DB row, so cornerstones
  are immediately servable after a sync without a separate rebuild step.

- scenario_pdf(): replace d = json.loads(scenario["calc_json"]) with
  d = calc(state) so all current calc fields (moic, dscr, cashOnCash, …)
  are present and the PDF route no longer 500s on stale stored JSON.

- Restored data/content/articles/ cornerstone .md files via git checkout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:09:19 +01:00
Deeman
8d86669360 merge: article bulk select-all matching + cornerstone filter
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s
2026-03-06 23:48:26 +01:00
Deeman
7d523250f7 feat(admin): article bulk select-all matching + cornerstone filter
- Extract _build_article_where() helper, eliminating duplicated WHERE
  logic from _get_article_list() and _get_article_list_grouped()
- Add template_slug='__manual__' sentinel → filters template_slug IS NULL
  (cornerstone / hand-written articles without a pSEO template)
- Add GET /articles/matching-count endpoint returning count of articles
  matching current filter params (for the Gmail-style select-all banner)
- Extend POST /articles/bulk with apply_to_all=true mode: builds WHERE
  from filter params instead of explicit IDs; rebuild capped at 2,000,
  delete at 5,000
- Add "Manual" option to Template filter dropdown
- Add Gmail-style "select all matching" banner: appears when select-all
  checkbox is checked, fetches total count, lets user switch to
  apply_to_all mode with confirmation dialog
- Sync filter hidden inputs into bulk form on filter change; changing
  filters resets apply-to-all state and clears selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:47:10 +01:00
Deeman
fee0d6913b fix(pipeline): use sqlmesh plan --auto-apply instead of run
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
2026-03-06 22:34:58 +01:00
Deeman
71e08a5fa6 fix(pipeline): also update supervisor.py to use plan --auto-apply
Missed the Python supervisor module — same fix as supervisor.sh and
worker.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:59 +01:00
Deeman
27e86db6a1 fix(pipeline): use sqlmesh plan --auto-apply instead of sqlmesh run
`sqlmesh run` only re-evaluates intervals for already-planned models —
it does not detect new, modified, or deleted models. Switching to
`plan prod --auto-apply` ensures schema changes (like the new
location_profiles model) are picked up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:17 +01:00
Deeman
90754b8d9f chore: move ci.py to ~/.claude/scripts (uv inline script, no project dep)
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 2s
Script now lives globally as a uv inline-dependency script.
Removes per-project scripts/ci.py and the msgspec dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:51:36 +01:00
Deeman
277c92e507 chore: add scripts/ci.py for Gitea CI pipeline status
Copies ci.py from beanflows (same script, shared across projects).
Adds msgspec dev dependency required by the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:38:42 +01:00
Deeman
77ec3a289f feat(transform): H3 catchment index, res 5 k_ring(1) ~24km radius
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Merges worktree-h3-catchment-index. dim_locations now computes h3_cell_res5
(res 5, ~8.5km edge). location_profiles and dim_locations updated;
old location_opportunity_profile.sql already removed on master.

Conflict: location_opportunity_profile.sql deleted on master, kept deletion
and applied h3_cell_res4→res5 rename to location_profiles instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:45:45 +01:00
Deeman
f81d5f19da fix(transform): tighten H3 catchment to res 5 (~24km radius)
Res 4 + k_ring(1) gave ~50-60km effective radius, causing Oldenburg to
absorb Bremen (40km away) and destroying score differentiation.

Res 5 + k_ring(1) gives ~24km — captures adjacent Gemeinden (Delmenhorst
at 15km) without bleeding into unrelated cities at 40km+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:34:56 +01:00
Deeman
4d29ecf1d6 merge: unified location_profiles serving model + both scores on map tooltips
All checks were successful
CI / test (push) Successful in 55s
CI / tag (push) Successful in 3s
# Conflicts:
#	CHANGELOG.md
#	transform/sqlmesh_padelnomics/models/serving/location_opportunity_profile.sql
2026-03-06 14:03:55 +01:00
Deeman
a3b4e1fab6 docs: update CHANGELOG, CLAUDE.md, and comments for location_profiles
Update transform CLAUDE.md source integration map and conformed
dimensions table. Update CHANGELOG with unified model + tooltip
changes. Fix stale comments in dim_cities.sql and serving README.

Subtask 5/5: documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:45:08 +01:00
Deeman
8b794d24a6 feat(maps): show both scores in all map tooltips
Country map: avg Market Score + avg Opportunity Score.
City map: Market Score + Opportunity Score per city.
Opportunity map: Opportunity Score + Market Score per location.

Subtask 4/5: tooltip updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:42:36 +01:00
Deeman
688f2dd1ee refactor(web): update all references to location_profiles
Update api.py (3 endpoints), public/routes.py, analytics.py docstring,
pipeline_routes.py DAG, pipeline_query.html placeholder, and
test_pipeline.py fixtures to use the new unified model.

Subtask 3/5: web app references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:41:42 +01:00
Deeman
81b556b205 refactor(serving): replace old models with location_profiles
Delete city_market_profile.sql and location_opportunity_profile.sql.
Update downstream models (planner_defaults, pseo_city_costs_de,
pseo_city_pricing) to read from location_profiles instead.

Subtask 2/5: delete old models + update downstream SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:39:52 +01:00
Deeman
cda94c9ee4 feat(serving): add unified location_profiles model
Combines city_market_profile and location_opportunity_profile into a
single serving model at (country_code, geoname_id) grain. Both Market
Score and Opportunity Score computed per location. City data enriched
via LEFT JOIN dim_cities on geoname_id.

Subtask 1/5: create new model (old models not yet removed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:36:36 +01:00
Deeman
4fbd91b59b merge: automate h3 community extension install via sqlmesh config 2026-03-06 10:27:03 +01:00
Deeman
159d1b5b9a fix(transform): use community repository for h3 extension install
SQLMesh's extensions config supports dict form with 'repository' key,
which runs INSTALL h3 FROM community + LOAD h3 automatically at connect
time. No manual one-time install needed per machine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:26:56 +01:00
Deeman
fcd0c9b007 docs: update CHANGELOG with H3 catchment score v3 2026-03-06 10:20:15 +01:00
Deeman
f841ae105a merge: use full trademarked score names in map tooltips 2026-03-06 10:19:56 +01:00
Deeman
dec4f07fbb merge: H3 catchment index for Marktpotenzial-Score v3 2026-03-06 10:19:51 +01:00
Deeman
4e4ff61699 feat(transform): H3 catchment index for Marktpotenzial-Score v3
Add H3 res-4 regional catchment metrics (~15-18km radius, cell + 6
neighbours) to both the addressable market (25pts) and supply gap
(30pts) components of location_opportunity_profile.

Changes:
- config.yaml: add h3 to DuckDB extensions (requires one-time
  INSTALL h3 FROM community on each machine)
- dim_locations: add h3_cell_res4 column via h3_latlng_to_cell()
- location_opportunity_profile: add hex_stats + catchment CTEs;
  update score formula to use catchment_population and
  catchment_padel_courts; expose catchment_population,
  catchment_padel_courts, catchment_venues_per_100k as output cols

Motivation: local population underestimates functional market for
mid-size cities (e.g. Oldenburg ~170K misses surrounding Gemeinden).
H3 k_ring(1) captures the realistic driving-distance catchment
(~462km²) consistently across both score components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:43 +01:00
Deeman
f907f2cd60 fix(maps): use full trademarked score names in all map tooltips
"Score X/100" → "Padelnomics Market Score: X/100" on country map (markets
hub), city map (country overview). Opportunity map uses "Padelnomics
Opportunity Score: X/100". Consistent branding across all three map views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:08 +01:00
Deeman
3ad2885c84 merge: fix map bubble styling + improve hover UX 2026-03-06 10:11:26 +01:00
Deeman
e2f54552b0 fix(maps): restore score colors for non-article cities, improve hover UX
Non-article cities were fully gray (#9CA3AF), stripping informational value.
Now all cities show score-based colors (green/amber/red). Non-article cities
are differentiated via lower opacity, dashed border, desaturation, and
default cursor (no click handler). Tooltips show scores for all cities —
article cities get "Click to explore →", non-article cities get "Coming soon".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:09:58 +01:00
Deeman
07ca1ce15b merge: custom 404/500 error pages + smarter map city clicks 2026-03-06 10:01:50 +01:00
Deeman
be9b10c13f docs: update CHANGELOG with error pages and map improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:59:29 +01:00
Deeman
82d6333517 feat: differentiate cities with/without articles on country map
Cities without published articles appear in muted gray and are not
clickable. The cities.json API endpoint now queries SQLite for
published articles and adds a has_article boolean to each city row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:57:01 +01:00
Deeman
ed48936dad feat: add styled 404/500 error pages with i18n support
Custom error templates extending base.html with centered layout.
404 is context-aware: detects /markets/{country}/{city} paths and
shows city-specific message with link back to country overview.
Both pages support EN/DE translations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:55:13 +01:00
Deeman
e3bda5b816 merge: fix admin template preview UX issues (maps, article_stats route, dev debug mode) 2026-03-06 09:35:45 +01:00
Deeman
831233cb29 fix(admin): add missing article_stats route, 500 handler, dev debug mode
- Add /admin/articles/stats HTMX partial endpoint that was referenced
  by article_stats.html but never created (caused 500 during generation)
- Add @app.errorhandler(500) to log exceptions with traceback
- Switch dev_run.sh from Granian to Quart debug mode for browser
  tracebacks and auto-reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:34:22 +01:00
Deeman
c5327c4012 fix(maps): move VENUE_ICON creation after Leaflet loads
L.divIcon() was called at IIFE top level before the dynamic Leaflet
script loaded, throwing ReferenceError and preventing all maps from
rendering. Move icon creation into script.onload callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:01:54 +01:00
Deeman
4426ab2cb6 fix(admin): render Leaflet maps in template preview 2026-03-05 22:58:34 +01:00
Deeman
93c9408f6b fix(admin): render Leaflet maps in template preview
The .card wrapper has overflow:hidden which clips Leaflet's
absolutely-positioned tile layers. Override to overflow:visible
on the rendered-article card. Add .catch() to map fetch calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:58:27 +01:00
Deeman
84128a3a64 merge: fix map scripts in template preview 2026-03-05 22:33:16 +01:00
Deeman
e9b4faa05c fix(admin): move map scripts inline in template preview
Put Leaflet init scripts inside admin_content block instead of relying
on the scripts block inheritance chain through base_admin → base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:33:16 +01:00
Deeman
a834bb481d merge: load Leaflet maps in admin template preview 2026-03-05 22:29:13 +01:00
Deeman
9515ec8ae9 fix(admin): load Leaflet maps in template preview page
The /admin/templates/<slug>/preview/<key> page renders article HTML
directly but never loaded Leaflet CSS/JS, so country-map and city-map
divs appeared empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:29:00 +01:00
Deeman
fb99d6e0db merge: fix sqlmesh worker command + article preview maps 2026-03-05 22:14:37 +01:00
Deeman
4ee80603ef fix(articles): load Leaflet maps in article editor preview
The admin article preview iframe was missing Leaflet CSS/JS and had
scripts blocked by the sandbox policy, so map shortcodes rendered as
empty divs.

- Extract inline map script to static/js/article-maps.js (shared
  between article_detail.html and admin preview)
- Replace f-string preview doc with a proper Jinja template that
  includes Leaflet assets
- Add allow-scripts to iframe sandbox on both initial load and HTMX
  preview updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:03:50 +01:00
Deeman
2e42245ad5 fix(worker): use sqlmesh run prod instead of plan prod --auto-apply
`plan --auto-apply` only detects SQL model changes and won't re-run
for new data. `run prod` evaluates missing cron intervals and picks
up newly extracted data — matching the fix already applied to the
supervisor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:49:51 +01:00
Deeman
2f47d1e589 fix(pipeline): make availability chain incremental + fix supervisor
Convert the availability chain (stg_playtomic_availability →
fct_availability_slot → fct_daily_availability) from FULL to
INCREMENTAL_BY_TIME_RANGE so sqlmesh run processes only new daily
intervals instead of re-reading all files.

Supervisor changes:
- run_transform(): plan prod --auto-apply → run prod (evaluates
  missing cron intervals, picks up new data)
- git_pull_and_sync(): add plan prod --auto-apply before re-exec
  so model code changes are applied on deploy
- supervisor.sh: same plan → run change

Staging model uses a date-scoped glob (@start_ds) to read only
the current interval's files. snapshot_date cast to DATE (was
VARCHAR) as required by time_column.

Clean up redundant TRY_CAST(snapshot_date AS DATE) in
venue_pricing_benchmarks since it's already DATE from foundation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:34:02 +01:00
Deeman
ead12c4552 fix(planner): prevent chart containers from overflowing on small screens
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 2s
2026-03-05 18:27:33 +01:00
Deeman
c54eb50004 fix(planner): prevent chart containers from overflowing on small screens
Grid children default to min-width:auto, letting the Chart.js canvas
push the container wider than its grid track. Adding min-width:0 and
overflow:hidden constrains charts to their column width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:27:27 +01:00
Deeman
5d7fcec17a chore: change GISCO extraction schedule from monthly to yearly
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
2026-03-05 17:50:19 +01:00
Deeman
f7faf7ab57 chore: change GISCO extraction schedule from monthly to yearly
NUTS2 boundaries rarely change; yearly (Jan 1) is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:50:14 +01:00
Deeman
add5f8ddfa fix(extract): correct lc_lci_lev lcstruct filter value
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:39:37 +01:00
Deeman
15ca316682 fix(extract): correct lc_lci_lev lcstruct filter value
D1_D2_A_HW doesn't exist in the API; use D1_D4_MD5 (total labour cost
= compensation + taxes - subsidies).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:32:49 +01:00
Deeman
103ef73cf5 fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:19:21 +01:00
Deeman
aa27f14f3c fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
- nrg_pc_203: add missing unit=KWH filter (API returns 2 units)
- lc_lci_lev: fix currency→unit filter dimension name
- supervisor: use `sqlmesh plan prod --auto-apply` instead of
  `sqlmesh run` so new/changed models are detected automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:19:12 +01:00
Deeman
8205744444 chore: remove accidentally committed .claire/ worktree directory
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:10:48 +01:00
Deeman
1cbefe349c add env var 2026-03-05 17:08:52 +01:00
Deeman
003f19e071 fix(pipeline): handle DuckDB catalog naming in diagnostic script 2026-03-05 17:07:52 +01:00
Deeman
c3f15535b8 fix(pipeline): handle DuckDB catalog naming in diagnostic script
The lakehouse.duckdb file uses catalog "lakehouse" not "local", causing
SQLMesh logical views to break. Script now auto-detects the catalog via
USE and falls back to physical tables when views fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:06:44 +01:00
Deeman
fcb8ec4227 merge: pipeline diagnostic script + extraction card UX improvements
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
2026-03-05 15:40:16 +01:00
Deeman
6b7fa45bce feat(admin): add pipeline diagnostic script + extraction card UX improvements
- Add scripts/check_pipeline.py: read-only diagnostic for pricing pipeline
  row counts, date range analysis, HAVING filter impact, join coverage
- Add description field to all 12 workflows in workflows.toml
- Parse and display descriptions on extraction status cards
- Show spinner + "Running" state with blue-tinted card border
- Display start time with "running..." text for active extractions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:40:12 +01:00
Deeman
0d8687859d fix(docker): copy workflows.toml into container for admin pipeline view
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
The admin Extraction Status page reads infra/supervisor/workflows.toml
but the Dockerfile only copied web/ into the image. Adding the COPY
so the file exists at /app/infra/supervisor/workflows.toml in the
container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:16:07 +01:00
Deeman
b064e18aa1 fix(admin): resolve workflows.toml path via CWD instead of __file__
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
In prod the package is installed in a venv, so __file__.parents[4] doesn't
reach the repo root. Use CWD (repo root in both dev and prod via systemd
WorkingDirectory) with REPO_ROOT env var override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:39:30 +01:00
Deeman
dc68976148 docs(marketing): add GTM, social posts, Reddit plan, and SEO calendar
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:43:11 +01:00
Deeman
60fa2bc720 test(billing): add Stripe E2E test scripts for sandbox validation
- test_stripe_sandbox.py: API-only validation of all 17 products (67 tests)
- stripe_e2e_setup.py: webhook endpoint registration via ngrok
- stripe_e2e_test.py: live webhook tests with real DB verification (67 tests)
- stripe_e2e_checkout_test.py: checkout webhook tests for credit packs,
  sticky boosts, and business plan PDF purchases (40 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:50:26 +01:00
Deeman
66c2dfce66 fix(billing): fetch line items for checkout.session.completed webhooks
_extract_line_items() was returning [] for all checkout sessions, which
meant _handle_transaction_completed never processed credit packs, sticky
boosts, or business plan PDF purchases. Now fetches line items from the
Stripe API using the session ID, with a fallback to embedded line_items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:49:41 +01:00
Deeman
6e3c5554aa fix(admin): enable bulk actions in grouped articles view
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
- dev_run.sh: also remove app.db-shm and app.db-wal on reset to fix
  SQLite disk I/O error from stale WAL/SHM files
- articles bulk: add checkboxes to grouped rows (data-ids holds all
  variant IDs); checking a group selects EN+DE together
- restore select-all checkbox in grouped <th>
- add toggleArticleGroupSelect() JS function
- fix htmx:afterSwap to re-check group checkboxes correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 09:48:54 +01:00
Deeman
ad02140594 fix(quote): add missing required asterisk and error hint to step 4
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Step 4 (Project Phase) required location_status server-side but had no
visual "*" indicator and no error message when submitting without a
selection. All other steps already had both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:21:57 +01:00
Deeman
5bcd87d7e5 fix(ci): replace non-existent quote.wizard endpoint with leads.quote_request
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
The CRO homepage overhaul (f4f8a45) introduced url_for('quote.wizard')
in landing.html, but that endpoint never existed — the actual route is
leads.quote_request. This broke CI runs #99–#109.

Also adds landing_vs_col_us to i18n allowlist (brand name, same in both
languages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:12:45 +01:00
Deeman
77772b7ea4 feat(maps): beanflows-style divIcon bubbles + feature flag gate
Replace L.circleMarker with L.divIcon + .pn-marker CSS class (white
border, box-shadow, hover scale) matching the beanflows growing
conditions map pattern. Dark .map-tooltip CSS override (no arrow,
dark navy background). Small venue dots use .pn-venue class.

Add _require_maps_flag() to all 4 API endpoints (default=True so
dev works without seeding the flag row). Gate /opportunity-map route
the same way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 20:51:00 +01:00
Deeman
59f1f0d699 merge(worktree): interactive maps for market pages
Self-hosted Leaflet 1.9.4 maps across 4 placements: markets hub
country bubbles, country overview city bubbles, city venue dots, and
a standalone opportunity map. New /api blueprint with 4 JSON endpoints.
New city_venue_locations SQLMesh serving model. No CDN — GDPR-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-03-04 15:36:41 +01:00
Deeman
0a89ba2213 docs: update CHANGELOG with interactive maps feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:33:35 +01:00
Deeman
6e936dbb95 feat(maps): Phase 5 — standalone opportunity map page
New route GET /<lang>/opportunity-map renders a full-width Leaflet map
with a country selector. On country change, fetches
/api/opportunity/{slug}.json and renders opportunity circles
(color-coded by score, sized by population) plus existing-venue gray
reference dots from /api/markets/{country}/cities.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:56 +01:00
Deeman
edf678ac4e feat(maps): Phase 4 — city venue dot map
New serving model: city_venue_locations joins dim_venues + dim_cities
to expose lat/lon/court_count per venue for the city dot map endpoint.

pseo_city_costs_de.sql: add c.lat, c.lon so city-cost articles have
city coordinates for the #city-map data attributes.

city-cost-de.md.jinja: add #city-map div (both DE and EN sections)
after the stats strip. Leaflet init handled by article_detail.html.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:07:06 +01:00
Deeman
0eef455543 feat(maps): Phase 3 — country overview city bubble map + article_detail Leaflet loader
Add #country-map div to country-overview.md.jinja (both DE/EN).
article_detail.html: always include Leaflet CSS, conditionally load
Leaflet JS only when #country-map or #city-map divs are present.
Initializes country city-bubble map and city venue-dot map from
/api/markets/{slug}/cities.json and /api/markets/{country}/{city}/venues.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:15:41 +01:00
Deeman
8e53fda283 feat(maps): Phase 2 — markets hub country bubble map
Add Leaflet map to /markets with country-level bubbles sized by
total_venues and colored by avg_market_score. Click navigates to
country overview page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:04:40 +01:00
Deeman
db0d7cfee9 feat(maps): Phase 1 — Leaflet vendor files, API blueprint, app registration
Self-host Leaflet 1.9.4 JS/CSS/images in static/vendor/leaflet/.
Create api.py blueprint with 4 JSON endpoints for map data.
Register api_bp at /api in app.py (before content catch-all).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:46:13 +01:00
Deeman
61c197d233 merge(worktree): individualise article costs with per-country Eurostat data + tiered proxy tenant work
# Conflicts:
#	CHANGELOG.md
#	transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql
#	transform/sqlmesh_padelnomics/models/foundation/dim_locations.sql
2026-03-04 12:44:56 +01:00
Deeman
2e68cfbe4f feat(transform): individualise article costs with per-country Eurostat data
Add real per-country cost data to ~30 calculator fields so pSEO articles
show country-specific CAPEX/OPEX instead of hardcoded DE defaults.

Extractor:
- eurostat.py: add 8 new datasets (nrg_pc_205, nrg_pc_203, lc_lci_lev,
  5×prc_ppp_ind variants); add optional `dataset_code` field so multiple
  dict entries can share one Eurostat API endpoint

Staging (4 new models):
- stg_electricity_prices — EUR/kWh by country, semi-annual
- stg_gas_prices         — EUR/GJ by country, semi-annual
- stg_labour_costs       — EUR/hour by country, annual (future staffed scenario)
- stg_price_levels       — PLI indices (EU27=100) for 5 categories, annual

Foundation:
- dim_countries (new) — conformed country dimension; eliminates ~50-line CASE
  blocks duplicated in dim_cities/dim_locations; computes ~29 calculator cost
  override columns from PLI ratios and energy price ratios vs DE baseline;
  NULL for DE so calculator falls through to DEFAULTS unchanged
- dim_cities — replace country_name/slug CASE blocks + country_income CTE
  with JOIN dim_countries
- dim_locations — same refactor as dim_cities

Serving:
- pseo_city_costs_de — JOIN dim_countries; add 29 camelCase override columns
  auto-applied by calculator (electricity, heating, rentSqm, hallCostSqm, …)
- planner_defaults — JOIN dim_countries; same 29 cost columns flow through
  to /api/market-data endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:09:48 +01:00
Deeman
7af6f32a2b merge: bulk actions for articles and leads
Some checks failed
CI / test (push) Failing after 33s
CI / tag (push) Has been skipped
2026-03-04 09:55:19 +01:00
Deeman
53fdbd9fd5 docs: update CHANGELOG with bulk actions feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:55:04 +01:00
Deeman
81487d6f01 feat(admin): bulk actions for articles and leads
Add bulk selection checkboxes and action bars to the articles and leads
admin pages, replicating the existing supplier bulk pattern.

Articles: publish, unpublish, toggle noindex, rebuild, delete (with
confirmation dialog). Leads: set status, set heat. Both re-render the
results partial after action via HTMX, preserving current filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:40:26 +01:00
Deeman
477f635bc5 test(billing): Stripe E2E webhook lifecycle tests
Some checks failed
CI / test (push) Failing after 29s
CI / tag (push) Has been skipped
2026-03-03 18:17:10 +01:00
Deeman
4dbded74ca test(billing): add Stripe E2E webhook lifecycle tests
16 tests covering the full Stripe webhook flow through /billing/webhook/stripe:
- Subscription creation (customer.subscription.created → DB row)
- Period end extraction from items (Stripe API 2026-02+ compatibility)
- Billing customer creation
- Status updates (active, past_due, trialing)
- Cancellation (customer.subscription.deleted → cancelled)
- Payment failure (invoice.payment_failed → past_due)
- One-time payments (checkout.session.completed mode=payment)
- Full lifecycle: create → update → recover → cancel
- Edge cases: missing metadata, empty items, invalid JSON, bad signatures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:17:05 +01:00
Deeman
230406f34f fix(billing): period_end from Stripe items + test 2026-03-03 18:06:01 +01:00
Deeman
7da6a4737d fix(billing): extract current_period_end from Stripe subscription items
Stripe API 2026-02+ moved current_period_end from subscription to
subscription items. Add _get_period_end() helper that falls back to
items[0].current_period_end when the subscription-level field is None.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:05:55 +01:00
Deeman
710e21a186 fix(billing): handle customer.subscription.created + test isolation 2026-03-03 17:58:15 +01:00
Deeman
72c4de91b0 fix(billing): handle customer.subscription.created webhook + test isolation
- Add customer.subscription.created → subscription.activated mapping in
  stripe.parse_webhook so direct API subscription creation also creates DB rows
- Add customer.subscription.created to setup_stripe.py enabled_events
- Pin PAYMENT_PROVIDER=paddle and STRIPE_WEBHOOK_SECRET="" in test conftest
  so billing tests don't hit real Stripe API when env has Stripe keys
- Add 8 unit tests for stripe.parse_webhook covering all event types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:29:13 +01:00
Deeman
046be665db merge: fix remaining request_options in stripe.py 2026-03-03 16:46:48 +01:00
Deeman
7c5fa86fb8 fix(billing): remove remaining request_options from Price.retrieve calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:46:25 +01:00
Deeman
0a9f980813 merge: fix Stripe SDK request_options + webhook endpoint graceful failure 2026-03-03 16:36:58 +01:00
Deeman
2682e810fa fix(billing): remove invalid request_options from Stripe SDK calls
Stripe Python SDK doesn't accept request_options as a kwarg to create/retrieve/modify.
Timeouts are handled by the global max_network_retries setting.
Also gracefully handle webhook endpoint creation failure for localhost URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:36:47 +01:00
Deeman
10af6a284c fix(content): slug transliteration, article links, country overview ranking
Some checks failed
CI / test (push) Failing after 30s
CI / tag (push) Has been skipped
# Conflicts:
#	CHANGELOG.md
2026-03-03 16:29:41 +01:00
Deeman
68f354ac2b docs: update CHANGELOG for slug fix + country overview ranking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:26:55 +01:00
Deeman
0b74156ef7 merge: accept alternative Stripe env var names 2026-03-03 16:24:25 +01:00
Deeman
fab16cb48f fix(billing): accept STRIPE_API_PRIVATE_KEY / STRIPE_API_PUBLIC_KEY env var names
Also normalise PAYMENT_PROVIDER to lowercase so STRIPE/stripe both work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:24:03 +01:00
Deeman
062a6d2766 merge: Stripe payment provider (dispatch-by-config alongside Paddle) 2026-03-03 16:07:52 +01:00
Deeman
80c2f111d2 feat(billing): B4-B5 — tests, lint fixes, CHANGELOG + PROJECT.md
- Fix unused imports in stripe.py (hashlib, hmac, time)
- Update test_billing_routes.py: insert into payment_products table,
  fix mock paths for extracted paddle.py, add Stripe webhook 404 test
- Update CHANGELOG.md with Stripe provider feature
- Update PROJECT.md Done section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:07:30 +01:00
Deeman
7ae8334d7a feat(billing): B3 — setup_stripe.py product/price creation script
Mirrors setup_paddle.py structure:
- Creates 17 products + prices in Stripe (same keys, same prices)
- Writes to payment_products table with provider='stripe'
- Registers webhook endpoint at /billing/webhook/stripe
- tax_behavior='exclusive' (price + VAT on top, EU standard)
- Supports --sync flag to re-populate from existing Stripe products

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:38 +01:00
Deeman
032fe8d86c feat(billing): B2 — Stripe payment provider implementation
billing/stripe.py exports the same interface as paddle.py:
- build_checkout_payload() → Stripe Checkout Session with automatic_tax
- build_multi_item_checkout_payload() → multi-line-item sessions
- cancel_subscription() → cancel_at_period_end=True
- get_management_url() → Stripe Billing Portal session
- verify_webhook() → Stripe-Signature header verification
- parse_webhook() → maps Stripe events to shared format:
  checkout.session.completed → subscription.activated / transaction.completed
  customer.subscription.updated → subscription.updated
  customer.subscription.deleted → subscription.canceled
  invoice.payment_failed → subscription.past_due

All API calls have 10s timeout and max 2 retries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:48:08 +01:00
Deeman
4907bc8b64 feat(billing): B1 — add stripe SDK dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:37:29 +01:00
Deeman
bf69270913 feat(billing): A6 — planner/supplier routes use get_price_id() + _provider()
- planner/routes.py: import get_price_id instead of get_paddle_price,
  export_checkout uses _provider().build_checkout_payload()
- suppliers/routes.py: all get_paddle_price → get_price_id,
  signup_checkout uses _provider().build_multi_item_checkout_payload(),
  dashboard boosts use get_all_price_ids() bulk load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:36:12 +01:00
Deeman
8f0a56079f feat(billing): A5 — dual-path JS templates for Paddle overlay / Stripe redirect
- New _payment_js.html: conditionally loads Paddle.js or nothing (Stripe
  uses server-side Checkout Session). Provides startCheckout() helper.
- All checkout templates use _payment_js.html instead of _paddle.html
- export.html, signup_step_4.html: Paddle.Checkout.open() → startCheckout()
- dashboard_boosts.html: inline onclick → buyItem() with server round-trip
- New /billing/checkout/item endpoint for single-item purchases (boosts, credits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:52 +01:00
Deeman
7af9b2c82c feat(billing): A2+A4 — extract paddle.py + dispatch layer in routes.py
- New billing/paddle.py: Paddle-specific functions (build_checkout_payload,
  cancel_subscription, get_management_url, verify_webhook, parse_webhook)
- routes.py: _provider() dispatch function selects paddle or stripe module
- Checkout/manage/cancel routes now delegate to _provider()
- /webhook/paddle always active (existing subscribers)
- /webhook/stripe endpoint added (returns 404 until Stripe configured)
- Shared _handle_webhook_event() processes normalized events from any provider
- _price_id_to_key() queries payment_products with paddle_products fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:26:47 +01:00
Deeman
276328af33 feat(billing): A1+A3 — payment_products table + provider-agnostic price lookups
- Migration 0028: create payment_products table, copy paddle_products rows
- Add STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET config
- Make PAYMENT_PROVIDER read from env (was hardcoded "paddle")
- Add get_price_id() / get_all_price_ids() querying payment_products
- Keep get_paddle_price() as deprecated fallback alias

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:07:10 +01:00
Deeman
a00c8727d7 fix(content): slugify transliteration + article links + country overview ranking
- Add @slugify SQLMesh macro (STRIP_ACCENTS + ß→ss) replacing broken
  inline REGEXP_REPLACE that dropped non-ASCII chars (Düsseldorf → d-sseldorf)
- Apply @slugify to dim_venues, dim_cities, dim_locations
- Fix Python slugify() to pre-replace ß→ss before NFKD normalization
- Add language prefix to B2B article market links (/markets/germany → /de/markets/germany)
- Change country overview top-5 ranking: venue count (not raw market_score)
  for top cities, population for top opportunity cities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:46:30 +01:00
Deeman
0fc0ca66b1 fix(i18n): replace smart quotes with straight quotes in sup_hero_sub
Some checks failed
CI / test (push) Failing after 29s
CI / tag (push) Has been skipped
Curly quotes (U+201C/U+201D) were used as JSON key/value delimiters
on line 894 of both locale files, making them invalid JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:39 +01:00
Deeman
385deb7f81 feat(cro): CRO overhaul — homepage + supplier landing pages (JTBD rewrite)
Some checks failed
CI / test (push) Failing after 9s
CI / tag (push) Has been skipped
2026-03-03 06:44:30 +01:00
Deeman
3ddb26ae0f chore: update CHANGELOG.md and PROJECT.md for CRO overhaul
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:44:19 +01:00
Deeman
695e956501 feat(cro): German translations for all CRO copy changes
Native-quality DE translations for homepage + supplier page:
- Hero, ROI, features, FAQ, final CTA, meta/SEO
- Proof strip, struggling moments, "Why Padelnomics" comparison
- Supplier proof points, ROI line, struggling moments, pricing CTAs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:42:16 +01:00
Deeman
a862d21269 feat(cro): supplier page CRO — struggling moments, conditional stats, honest proof
Task 3: Add "Is this your sales team?" struggling-moments section.
Conditional stats display (hide if below thresholds). Replace anonymous
testimonials with data-backed proof points. Tier-specific pricing CTAs.
Tighter hero sub-headline. Move ROI callout above pricing grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:36:46 +01:00
Deeman
f4f8a45654 feat(cro): homepage structural overhaul — proof strip, struggling moments, comparison
Task 2: Remove journey timeline (3 "SOON" badges = incomplete signal).
Add proof strip below hero with live stats. Add "Sound familiar?"
section with 4 JTBD struggling-moment cards. Add "Why Padelnomics"
3-column comparison (DIY vs consultant vs us). Update hero secondary
CTA and supplier matching links to /quote. Route handler now passes
calc_requests and total_budget_millions to template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:33:43 +01:00
Deeman
9e471f8960 feat(cro): rewrite homepage EN copy — outcome-focused JTBD framing
Task 1: Hero, features, FAQ, final CTA, supplier matching, meta/SEO
strings all rewritten. New keys added for proof strip, struggling-
moments section, and "Why Padelnomics" comparison section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:31:24 +01:00
Deeman
48401bd2af feat(articles): rewrite B2B article CTAs — directory → /quote form
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
All 12 hall-building articles now link to /quote (leads.quote_request).
Previously: 2 had broken directory prose, 4 had unlinked planner mentions,
4 had broken [→ placeholder] links, 2 had scenario cards but no CTA link.

- Group 1 (bauen/build-guide): replace directory section with quote CTA
- Group 2 (kosten/risiken): link planner refs, append quote CTA
- Group 3 (finanzierung): append quote CTA after scenario card
- Group 4 (standort/businessplan): fix broken [→] links to /de|en/planner,
  append quote CTA

CTA copy is contextual per article. Light-blue banner pattern, .btn class.
B2C gear articles unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:28 +01:00
Deeman
cd02726d4c chore(changelog): document B2B article CTA rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:20 +01:00
Deeman
fbc259cafa fix(articles): fix broken CTA links + add /quote CTA in location and business plan articles
- padel-standort-analyse-de, padel-hall-location-guide-en:
  fix [→ ...] placeholders to /de/planner and /en/planner
  append quote CTA "Standort gefunden? Angebote einholen"
- padel-business-plan-bank-de, padel-business-plan-bank-requirements-en:
  fix [→ Businessplan erstellen] / [→ Generate your business plan] to planner
  append quote CTA "Bankfähige Zahlen plus passende Baupartner"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:39:59 +01:00
Deeman
992e448c18 fix(articles): add /quote CTA after scenario card in financing articles
Appends contextual quote CTA block to padel-halle-finanzierung-de.md
and padel-hall-financing-germany-en.md after the scenario card embed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:29:01 +01:00
Deeman
777a4af505 fix(articles): add /quote CTA + planner links in cost and risk articles
- padel-halle-kosten-de, padel-hall-cost-guide-en: link planner ref,
  append quote CTA "Zahlen prüfen — Angebote einholen"
- padel-halle-risiken-de, padel-hall-investment-risks-en: link planner
  in sensitivity tab mention, append quote CTA on risk management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:18:46 +01:00
Deeman
2c8c662e9e fix(articles): replace directory CTA with /quote in build guides
Removes the broken "find suppliers" directory section from
padel-halle-bauen-de.md and padel-hall-build-guide-en.md.
Replaces with a contextual light-blue quote CTA block linking to /quote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:17:28 +01:00
Deeman
34f8e45204 merge(articles): iframe preview + collapsible meta + word count 2026-03-02 12:09:04 +01:00
Deeman
6b9187f420 fix(articles): iframe preview + collapsible meta + word count
Replace the auto-escaped `{{ body_html }}` div (showed raw HTML tags)
with a sandboxed `<iframe srcdoc>` pattern matching the email preview.
Both the initial page load and the HTMX live-update endpoint now build
a full `preview_doc` document embedding the public CSS and wrapping
content in `<div class="article-body">` — pixel-perfect against the
live article, admin styles fully isolated.

Also:
- Delete ~65 lines of redundant `.preview-body` custom CSS
- Add "Meta ▾" toolbar toggle to collapse/expand metadata strip
- Add word count footer in the editor pane (updates on input)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:01:16 +01:00
Deeman
94d92328b8 merge: fix article .md lookup + lighter editor
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 11:47:13 +01:00
Deeman
100e200c3b fix(articles): find .md by slug scan + lighter editor theme
Two fixes:
- _find_article_md() scans _ARTICLES_DIR for files whose frontmatter
  slug matches, so padel-halle-bauen-de.md is found for slug
  'padel-halle-bauen'. The previous exact-name lookup missed any file
  where the filename ≠ slug (e.g. {slug}-{lang}.md naming convention).
- Editor pane: replace dark navy background with warm off-white (#FEFDFB)
  and dark text so it reads like a document, not a code editor.
2026-03-02 11:43:26 +01:00
Deeman
70628ea881 merge(pipeline-transform-tab): split article editor + frontmatter fix + transform tab features
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
2026-03-02 11:34:13 +01:00
Deeman
d619f5e1ef feat(articles): split editor with live preview + fix frontmatter bug
Bug: article_edit GET was passing raw .md file content (including YAML
frontmatter) to the body textarea. Articles synced from disk via
_sync_static_articles() had their frontmatter bled into the editor,
making it look like content was missing or garbled.

Fix: strip frontmatter (using existing _FRONTMATTER_RE) before setting
body, consistent with how _rebuild_article() already does it.
Also switch to _ARTICLES_DIR (absolute) instead of relative path.

New: split editor layout — compact metadata strip at top, dark
monospace textarea on the left, live rendered preview on the right
(HTMX, 500ms debounce). Initial preview server-rendered on page load.
New POST /admin/articles/preview endpoint returns the preview partial.
2026-03-02 11:10:01 +01:00
Deeman
2a7eed1576 merge: test suite compression pass (-197 lines)
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 10:46:01 +01:00
Deeman
162e633c62 refactor(tests): compress admin_client + mock_send_email into conftest
Lift admin_client fixture from 7 duplicate definitions into conftest.py.
Add mock_send_email fixture, replacing 60 inline patch() blocks across
test_emails.py, test_waitlist.py, and test_businessplan.py. Net -197 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:40:52 +01:00
Deeman
31017457a6 merge: semantic-compression — add compression helpers, macros, and coding philosophy
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
Applies Casey Muratori's semantic compression across all three packages:
- count_where() helper: 30+ COUNT(*) call sites compressed
- _forward_lead(): deduplicates lead forward routes
- 5 SQLMesh macros for country code patterns (7 models)
- skip_if_current() + write_jsonl_atomic() extract helpers
Net: -118 lines (272 added, 390 removed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:00:15 +01:00
Deeman
f93e4fd0d1 chore(changelog): document semantic compression pass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:54:44 +01:00
Deeman
567798ebe1 feat(extract): add skip_if_current() and write_jsonl_atomic() helpers
Task 5/6: Compress repeated patterns in extractors:
- skip_if_current(): cursor check + early-return dict (3 extractors)
- write_jsonl_atomic(): working-file → JSONL → compress (2 extractors)
Applied in gisco, geonames, census_usa, playtomic_tenants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:49:18 +01:00
Deeman
b32b7cd748 merge: unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminates confirmAction() entirely. One code path: all confirmations
go through showConfirm() called by the htmx:confirm interceptor.
14 template files converted to hx-boost + hx-confirm pattern.
Pipeline endpoints updated to exclude HX-Boosted requests from the
HTMX partial path.

# Conflicts:
#	web/src/padelnomics/admin/templates/admin/affiliate_form.html
#	web/src/padelnomics/admin/templates/admin/affiliate_program_form.html
#	web/src/padelnomics/admin/templates/admin/base_admin.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html
2026-03-02 07:48:49 +01:00
Deeman
6774254cb0 feat(sqlmesh): add country code macros, apply across models
Task 4/6: Add 5 macros to compress repeated country code patterns:
- @country_name / @country_slug: 20-country CASE in dim_cities, dim_locations
- @normalize_eurostat_country / @normalize_eurostat_nuts: EL→GR, UK→GB
- @infer_country_from_coords: bounding box for 8 markets
Net: +91 lines in macros, -135 lines in models = -44 lines total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:45:52 +01:00
Deeman
e87a7fc9d6 refactor(admin): extract _forward_lead() from duplicate lead forward routes
Task 3/6: lead_forward and lead_forward_htmx shared ~20 lines of
identical DB logic. Extracted into _forward_lead() that returns an
error string or None. Both routes now call the helper and differ
only in response format (redirect vs HTMX partial).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:43:50 +01:00
Deeman
3d7a72ba26 refactor: apply count_where() across remaining web blueprints
Task 2/6 continued: Compress 18 more COUNT(*) call sites across
suppliers, directory, dashboard, public, planner, pseo, and pipeline
routes. -24 lines net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:40:24 +01:00
Deeman
a55501f2ea feat(core): add count_where() helper, compress admin COUNT queries
Task 2/6: Adds count_where(table_where, params) to core.py that
compresses the fetch_one + null-check COUNT(*) pattern. Applied
across admin/routes.py — dashboard stats shrinks from ~75 to ~25
lines, plus 10 more call sites compressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:35:33 +01:00
Deeman
d3626193c5 refactor(admin): unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminate `confirmAction()` and the duplicate `cloneNode` hack entirely.
One code path: everything goes through `showConfirm()` called by the
`htmx:confirm` interceptor.

Dialog HTML:
- `<form method="dialog">` for native close semantics; button `value`
  becomes `dialog.returnValue` — no manual event listener reassignment.

JS:
- `showConfirm(message)` — Promise-based, listens for `close` once.
- `htmx:confirm` handler calls `showConfirm()` and calls `issueRequest`
  if confirmed. Replaces both the old HTMX handler and `confirmAction()`.

Templates (Padelnomics, 14 files):
- All `onclick=confirmAction(...)` and `onclick=confirm()` removed.
- Form-submit buttons: added `hx-boost="true"` to form + `hx-confirm`
  on the submit button.
- Pure HTMX buttons (pipeline_transform, pipeline_overview): `hx-confirm`
  replaces `onclick=if(!confirm(...))return false;`.

Pipeline routes (pipeline_trigger_extract, pipeline_trigger_transform):
- `is_htmx` now excludes `HX-Boosted: true` requests — boosted form
  POSTs get the normal redirect instead of the inline partial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 07:35:32 +01:00
Deeman
7ea1f234e8 chore(changelog): document htmx:confirm guard fix
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:40:07 +01:00
Deeman
c1cf472caf fix(admin): guard htmx:confirm handler against empty question
The handler called evt.preventDefault() unconditionally, so auto-poll
requests (hx-trigger="every 5s", no hx-confirm) caused an empty dialog
to pop up every 5 seconds. Add an early return when evt.detail.question
is falsy so only actual hx-confirm interactions are intercepted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:39:38 +01:00
Deeman
f9e22a72dd merge: fix CI — update proxy tests for 2-tier design
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:36:35 +01:00
Deeman
ce466e3f7f test(proxy): update supervisor tests for 2-tier proxy (no Webshare)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:36:30 +01:00
Deeman
563bd1fb2e merge: tiered-proxy-tenants — gisco extractor, proxy fixes, recheck datetime fix
Some checks failed
CI / test (push) Failing after 46s
CI / tag (push) Has been skipped
- feat: GISCO NUTS-2 extractor module (replaces standalone script)
- feat: wire 5 unscheduled extractors into workflows.toml
- fix: add load_dotenv() to _shared.py so .env proxies are picked up
- fix: recheck datetime parsing (HH:MM:SS slot times need start_date prefix)
- fix: graceful 0-venue early return in recheck
- fix(proxy): remove Webshare free tier — DC tier 1, residential tier 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:12:17 +01:00
Deeman
b980b8f567 fix(proxy): remove Webshare free tier — DC tier 1, residential tier 2
Free Webshare proxies were timing out and exhausting the circuit breaker
before datacenter/residential proxies got a chance to run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:12:08 +01:00
Deeman
0733f1c2a1 docs(scratch): rename guide → question bank with full gap analysis
Transforms the raw question bank into an annotated gap analysis document:
- Every section tagged ANSWERED / PARTIAL / GAP
- Summary table of 13 gaps across 3 tiers with impact and feasibility
- Inline actionable notes linking to research files, planner inputs, and backlog

Key findings captured:
- Tier 1 gaps: subsidies/grants, buyer segmentation, indoor-vs-outdoor decision
  framework, OPEX benchmark display
- Tier 2 gaps: booking platform strategy, depreciation/tax shield, legal/regulatory
  checklist (DE), supplier selection framework, staffing plan template
- Tier 3 gaps: zero-court pSEO pages, pre-opening playbook, drive-time isochrones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:30:27 +01:00
Deeman
320777d24c update env vars
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
2026-03-01 21:28:45 +01:00
Deeman
92930ac717 fix(extract): handle 0-result recheck gracefully — skip file write
When all proxy tiers are exhausted and 0 venues are fetched, the working
file is empty and compress_jsonl_atomic asserts non-empty. Return early
with a warning instead of crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:25:09 +01:00
Deeman
0cfc841c08 merge: fix recheck 0-result crash
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-01 21:25:09 +01:00
182 changed files with 13010 additions and 1836 deletions

View File

@@ -31,12 +31,18 @@ RESEND_WEBHOOK_SECRET=
#ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,type:comment]
#ENC[AES256_GCM,data:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment]
#ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment]
#ENC[AES256_GCM,data:VXv1O5oRNTws8wbx/nZWH6Q=,iv:M/XwF6Zef+xlJ/8AAVI1zSmsEUNYL+0twzxXwkf8moY=,tag:y3Nu5akuiKtEIMeZhSNIkw==,type:comment]
PAYMENT_PROVIDER=ENC[AES256_GCM,data:7uxz3xmr,iv:4uEOA7ZjehD1bF91Gxl0+OxnvlZW3QIq22MhnYM43uE=,tag:XvHqyRM+ugnWTUN9GFJ3fQ==,type:str]
#ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment]
PADDLE_API_KEY=
PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET=
PADDLE_NOTIFICATION_SETTING_ID=
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str]
#ENC[AES256_GCM,data:sk79dbsswA==,iv:J8CyJt/WOMLd7CZNutDwIOtAOAooaMsLPO35gfWo+Nc=,tag:JQcGMYdgcQgtIWKcqXZkNQ==,type:comment]
STRIPE_API_PUBLIC_KEY=ENC[AES256_GCM,data:WhWvIzNd1sS+IrrEdE+FJI6ZgEiNlgG3oxC8VoDzXf0z1oH1wgY6m9wUq6UEZZyzeiRGAeAylOk6wHJ+Lx4+zx2cfv+yweX7I3Sq5VN2D1OBPiQ3Kde4zm5cXqA92jRkLAomZxw/DkeiB14=,iv:Rb3GSLMVSySR++X240MICsXbVtOuqZNjm+nIe+s65dU=,tag:z82dyRzmxF3e87Sm2F+4Qw==,type:str]
STRIPE_API_PRIVATE_KEY=ENC[AES256_GCM,data:/62y1Iv2Op21eEvT3BosgWD0S3YqGMgdfb2Edjhq2cuh32B3eH5fh9FaqBc3CvJpM7R79hy9jTnV3CTjlCkvrXGCLDnFY2a6kvSz5f+v2d/lsr8zvFLs6OP+bhssHdVygfIwz9ye46tfcFk=,iv:iw0NAYUf/gCM4awb2tKBEKuo/j7kkpVP6JjIIdVy7O8=,tag:GO3ASp5bykwHDHNkCYsdiA==,type:str]
STRIPE_ACCOUNT_ID=ENC[AES256_GCM,data:ahJsOgZLRi5n9P7Dy0U1rvmhwr/B,iv:aoVA3M8Faqv1kZwTtagD0WLVipkA5nkX5uSjtHl14+I=,tag:XwLOu9ZiHUizcsnk73bt1w==,type:str]
#ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str]
UMAMI_API_TOKEN=
@@ -73,7 +79,7 @@ GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj9
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-03-01T13:34:16Z
sops_mac=ENC[AES256_GCM,data:JLfGLbNTEcI6M/sUA5Zez6cfEUObgnUBmX52560PzBmeLZt0F5Y5QpeojIBqEDMuNB0hp1nnPI59WClLJtQ12VlHo9TkL3x9uCNUG+KneQrn1bTmJpA3cwNkWTzIm4l+TGbJbd4FpKJ9H0v1w+sqoKOgG8DqbtOeVdUfsVspAso=,iv:UqYxooXkEtx+y7fYzl+GFncpkjz8dcP7o9fp+kFf6w4=,tag:/maSb1aZGo+Ia8eGpB7PYw==,type:str]
sops_lastmodified=2026-03-03T15:16:35Z
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -3,6 +3,7 @@ APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvz
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:rn8u+tGob0vU7kSAtxmrpYQlneesvyO10A==,iv:PuGtdcQBdRbnybulzd6L7JVQClcK3/QjMeYFXZSxGW0=,tag:K2PJPMCWXdqTlQpwP9+DOQ==,type:str]
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
@@ -42,8 +43,8 @@ SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHz
PROXY_URLS_RESIDENTIAL=ENC[AES256_GCM,data:vxRcXQ/8TUTCtr6hKWBD1zVF47GFSfluIHZ8q0tt8SqQOWDdDe2D7Of6boy/kG3lqlpl7TjqMGJ7fLORcr0klKCykQ==,iv:YjegXXtIXm2qr0a3ZHRHxj3L1JoGZ1iQXkVXQupGQ2E=,tag:kahoHRskXbzplZasWOeiig==,type:str]
PROXY_URLS_DATACENTER=ENC[AES256_GCM,data:23TgU6oUeO7J+MFkraALQ5/RO38DZ3ib5oYYJr7Lj3KXQSlRsgwA+bJlweI5gcUpFphnPXvmwFGiuL6AeY8LzAQ3bx46dcZa5w9LfKw2PMFt,iv:AGXwYLqWjT5VmU02qqada3PbdjfC0mLK2sPruO0uru8=,tag:Z2IS/JPOqWX+x0LZYwyArA==,type:str]
WEBSHARE_DOWNLOAD_URL=ENC[AES256_GCM,data:/N77CFf6tJWCk7HrnBOm2Q1ynx7XoblzfbzJySeCjrxqiu4r+CB90aDkaPahlQKI00DUZih3pcy7WhnjdAwI30G5kJZ3P8H8/R0tP7OBK1wPVbsJq8prQJPFOAWewsS4KWNtSURZPYSCxslcBb7DHLX6ZAjv6A5KFOjRK2N8usR9sIabrCWh,iv:G3Ropu/JGytZK/zKsNGFjjSu3Wt6fvHaAqI9RpUHvlI=,tag:fv6xuS94OR+4xfiyKrYELA==,type:str]
PROXY_CONCURRENCY=ENC[AES256_GCM,data:vdEZ,iv:+eTNQO+s/SsVDBLg1/+fneMzEEsFkuEFxo/FcVV+mWc=,tag:i/EPwi/jOoWl3xW8H0XMdw==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
PROXY_CONCURRENCY=ENC[AES256_GCM,data:WWpx,iv:4RdNHXPXxFS5Yf1qa1NbaZgXydhKiiiEiMhkhQxD3xE=,tag:6UOQmBqj+9WlcxFooiTL+A==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:9wQ=,iv:QS4VfelUDdaDbIUC8SJBuy09VpiWM9QQcYliQ7Uai+I=,tag:jwkJY95qXPPrgae8RhKPSg==,type:str]
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
@@ -63,7 +64,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
sops_lastmodified=2026-03-01T17:40:31Z
sops_mac=ENC[AES256_GCM,data:xiTAz5BSk9F7GqQHcy0UpU7jCS2wHbfi27hOvpdoxAKtGLxaZ5PISQHVWEStWjHS+8g+3ACrTj/UQfUuCTr/55UVU0Wu6hyAWnuZ3DuaMfYUNer+9XZm5V2jTibQIYH01ZWyt4aeqs/Njn39FMx33s4hRdYVjfN391wgkx2+Hsg=,iv:UbgoSuVPu9H7Gu+HwZ6m60KgfGxZwKITMrkT54nd1yY=,tag:pM0hoz6XDQk6HaSJBkOR1Q==,type:str]
sops_lastmodified=2026-03-05T15:55:19Z
sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -6,6 +6,106 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.
### Added
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
### Fixed
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
### Added
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
- **City cost articles**: venue dot map centered on city lat/lon (zoom 13), navy dots per venue with tooltip showing name + court breakdown (indoor/outdoor).
- **Opportunity map** (`/<lang>/opportunity-map`): standalone full-width page with country selector. Circles sized by population, colored by opportunity score (green ≥ 70, amber 40-70, blue < 40). Existing venues shown as gray reference dots.
- New `/api` blueprint with 4 JSON endpoints (`/api/markets/countries.json`, `/api/markets/<country>/cities.json`, `/api/markets/<country>/<city>/venues.json`, `/api/opportunity/<country>.json`) — 1-hour public cache headers, all queries against `analytics.duckdb` via `fetch_analytics`.
- New SQLMesh serving model `city_venue_locations` exposing venue lat/lon + court counts per city.
- `pseo_city_costs_de` serving model: added `lat`/`lon` columns for city map data attributes in baked articles.
- Leaflet CSS included on all article pages (5KB, cached). JS loaded dynamically only when a map container is present.
- **Individualised article financial calculations with real per-country cost data** — ~30 CAPEX/OPEX calculator fields now scale to each country's actual cost level via Eurostat data, eliminating the identical DE-hardcoded numbers shown for every city globally.
- **New Eurostat datasets extracted** (8 new landing files): electricity prices (`nrg_pc_205`), gas prices (`nrg_pc_203`), labour costs (`lc_lci_lev`), and 5 price level index categories from `prc_ppp_ind` (construction, housing, services, misc, government).
- `extract/padelnomics_extract/src/padelnomics_extract/eurostat.py`: added 8 dataset entries; added `dataset_code` field support so multiple dict entries can share one Eurostat API endpoint (needed for 5 prc_ppp_ind variants).
- **4 new staging models**: `stg_electricity_prices`, `stg_gas_prices`, `stg_labour_costs`, `stg_price_levels` — all read from landing zone with ISO code normalisation (EL→GR, UK→GB).
- **New `foundation.dim_countries`** — conformed country dimension (grain: `country_code`). Consolidates country names/slugs and income data previously duplicated in `dim_cities` and `dim_locations` as ~50-line CASE blocks. Computes ~29 calculator cost override columns from Eurostat PLI indices and energy prices relative to DE baseline.
- **Refactored `dim_cities`** — removed ~50-line CASE blocks and `country_income` CTE; JOIN `dim_countries` for `country_name_en`, `country_slug`, `median_income_pps`, `income_year`.
- **Refactored `dim_locations`** — same refactor as `dim_cities`; income cascade still cascades EU NUTS-2 → US state → `dim_countries` country-level.
- **Updated `serving.pseo_city_costs_de`** — JOIN `dim_countries`; 29 new camelCase override columns (`electricity`, `heating`, `rentSqm`, `hallCostSqm`, …, `permitsCompliance`) auto-applied by calculator.
- **Updated `serving.planner_defaults`** — JOIN `dim_countries`; same 29 cost columns flow through to the planner API `/api/market-data` endpoint.
- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action.
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
- `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
- `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
- `billing/routes.py`: provider-agnostic dispatch layer — checkout, manage, cancel routes call `_provider().xxx()`
- `_payment_js.html`: dual-path JS — conditionally loads Paddle.js SDK, universal `startCheckout()` handles both overlay (Paddle) and redirect (Stripe)
- `scripts/setup_stripe.py`: mirrors `setup_paddle.py` — creates 17 products + prices in Stripe, registers webhook endpoint
- Migration 0028: `payment_products` table generalizing `paddle_products` with `provider` column; existing Paddle rows copied
- `get_price_id()` / `get_all_price_ids()` replace `get_paddle_price()` for provider-agnostic lookups
- Stripe config vars: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
- Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
- Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)
### Fixed
- **City slug transliteration** — replaced broken inline `REGEXP_REPLACE(LOWER(...), '[^a-z0-9]+', '-')` with new `@slugify` SQLMesh macro that uses `STRIP_ACCENTS` + `ß→ss` pre-replacement. Fixes: `Düsseldorf``dusseldorf` (was `d-sseldorf`), `Überlingen``uberlingen` (was `-berlingen`). Applied to `dim_venues`, `dim_cities`, `dim_locations`. Python `slugify()` in `core.py` updated to match.
- **B2B article market links** — added missing language prefix (`/markets/germany``/de/markets/germany` and `/en/markets/germany`). Without the prefix, Quart interpreted `markets` as a language code → 500 error.
- **Country overview top-5 city list** — changed ranking from raw `market_score DESC` (which inflated tiny towns with high density scores) to `padel_venue_count DESC` for top cities and `population DESC` for top opportunity cities. Germany now shows Berlin, Hamburg, München instead of Überlingen, Schwaigern.
### Changed
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
- **Proof strip**: live stats bar below hero (business plans created, suppliers, countries, project volume)
- **"Sound familiar?" section**: replaces the 5-step journey timeline (3 items said "SOON") with 4 struggling-moment cards from JTBD research
- **Feature cards reframed as outcomes**: "60+ Variables" → "Know Your Numbers Inside Out", "6 Analysis Tabs" → "Bank-Ready from Day One", "Sensitivity Analysis" → "Stress-Test Before You Commit", etc.
- **"Why Padelnomics" comparison**: 3-column section (DIY Spreadsheet vs. Hired Consultant vs. Padelnomics) from JTBD Competitive Job Map
- **FAQ rewritten**: customer-first questions ("How much does it cost to open a padel facility?", "Will a bank accept this?") replace product-internal questions
- **Final CTA**: "Your Bank Meeting Is Coming. Be Ready." replaces generic "Start Planning Today"
- **Supplier page**: "Is this your sales team?" struggling-moments section, conditional stats display (hides zeros), data-backed proof points replacing anonymous testimonials, ROI math moved above pricing, tier-specific CTAs
- **Meta/SEO**: updated page title and description for search intent
- All changes in both EN and DE (native-quality German, generisches Maskulinum)
### Fixed
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
- **`_forward_lead()` helper** (`web/admin/routes.py`): extracts shared DB logic from `lead_forward` and `lead_forward_htmx` — both routes now call the helper and differ only in response format.
- **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`.
- **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors).
- **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY.
- **Test suite compression pass** — applied same compression workflow to `web/tests/` (30 files, 13,949 lines). Net result: -197 lines across 11 files.
- **`admin_client` fixture** lifted from 7 duplicate definitions into `conftest.py`.
- **`mock_send_email` fixture** added to `conftest.py`, replacing 60 inline `with patch("padelnomics.worker.send_email", ...)` blocks across `test_emails.py` (51), `test_waitlist.py` (4), `test_businessplan.py` (2). Each refactored test drops one indentation level.
### Fixed
- **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds.
### Changed
- **Admin: styled confirm dialog for all destructive actions** — replaced all native `window.confirm()` calls with the existing `#confirm-dialog` styled `<dialog>`. A new global `htmx:confirm` handler intercepts HTMX confirmation prompts and shows the dialog; form-submit buttons on affiliate pages were updated to use `confirmAction()`. Affected: pipeline Transform tab (Run Transform, Run Export, Run Full Pipeline), pipeline Overview tab (Run extractor), affiliate product delete, affiliate program delete (both form and list variants).
- **Pipeline tabs: no scrollbar** — added `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` to `.pipeline-tabs` to suppress the spurious horizontal scrollbar on narrow viewports.
@@ -16,6 +116,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry.
### Changed
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
- `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
- `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
- 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
- Same changes mirrored to BeanFlows and quart-saas-boilerplate template
- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
- `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`

View File

@@ -25,6 +25,8 @@ WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=build --chown=appuser:appuser /app .
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
COPY --chown=appuser:appuser content/ ./content/
USER appuser
ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db

View File

@@ -60,6 +60,7 @@
- [x] Boost purchases (logo, highlight, verified, card color, sticky week/month)
- [x] Credit pack purchases (25/50/100/250)
- [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual)
- [x] **Stripe payment provider** — env-var toggle (`PAYMENT_PROVIDER=paddle|stripe`), Stripe Checkout Sessions + Billing Portal + webhook handling, `payment_products` table generalizes `paddle_products`, dual-path JS templates, `billing/paddle.py` + `billing/stripe.py` dispatch pattern, `setup_stripe.py` product creation script
- [x] **Feature flags** (DB-backed, migration 0019) — `is_flag_enabled()` + `feature_gate()` decorator replace `WAITLIST_MODE`; 5 flags (markets, payments, planner_export, supplier_signup, lead_unlock); admin UI at `/admin/flags` with toggle
- [x] **Pricing overhaul** — Basic free (no Paddle sub), card color €59, BP PDF €149; supplier page restructured value-first (why → guarantee → leads → social proof → pricing); all CTAs "Get Started Free"; static ROI line; credits-only callout
- [x] **Lead-Back Guarantee** (migration 0020) — 1-click credit refund for non-responding leads (330 day window); `refund_lead_guarantee()` in credits.py; "Lead didn't respond" button on unlocked lead cards
@@ -157,6 +158,7 @@
- [x] Padel racket SVG logo/favicon
- [x] Feedback widget (HTMX POST, rate-limited)
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
---

View File

@@ -0,0 +1,88 @@
---
title: "Die besten Padelschläger 2026: Unser ausführlicher Vergleich"
slug: beste-padelschlaeger-de
language: de
url_path: /beste-padelschlaeger-2026
meta_description: "Welcher Padelschläger ist der beste 2026? Wir haben die wichtigsten Modelle für Anfänger, Fortgeschrittene und Profis getestet und verglichen."
---
# Die besten Padelschläger 2026: Unser ausführlicher Vergleich
<!-- TODO: Einleitung mit Hauptkeyword und USP dieser Seite (200300 Wörter) -->
Wer einen neuen Padelschläger kaufen will, steht vor einer unüberschaubaren Auswahl. Mehr als 50 Marken, Hunderte von Modellen — und kein einziges unabhängiges Testlabor. Wir haben die meistverkauften und meistempfohlenen Schläger zusammengetragen und nach drei Kriterien bewertet: Spielgefühl, Haltbarkeit und Preis-Leistungs-Verhältnis.
---
## Unsere Top-Empfehlungen
[product-group:racket]
---
## Testsieger im Detail
<!-- TODO: Ausführliche Besprechung der Top 35 Modelle, je 300500 Wörter pro Schläger -->
### Platz 1: [Produktname einfügen]
[product:platzhalter-schlaeger-1-amazon]
<!-- TODO: Erfahrungsbericht + Vor- und Nachteile im Prosatext -->
### Platz 2: [Produktname einfügen]
[product:platzhalter-schlaeger-2-amazon]
### Platz 3: [Produktname einfügen]
[product:platzhalter-schlaeger-3-amazon]
---
## So haben wir getestet
<!-- TODO: Kurze Beschreibung der Testmethodik (23 Absätze) -->
---
## Kaufberatung: Welcher Schläger passt zu mir?
<!-- TODO: Entscheidungsbaum / Tabelle nach Spielertyp -->
| Spielertyp | Empfohlene Form | Empfohlenes Gewicht |
|---|---|---|
| Anfänger | Rund | 355365 g |
| Allspieler | Tropfen | 360370 g |
| Fortgeschrittener | Diamant | 365380 g |
---
## Häufige Fragen
<details>
<summary>Wie oft sollte man einen Padelschläger wechseln?</summary>
<!-- TODO: Antwort (50100 Wörter) -->
Bei regelmäßigem Spielen (23 Mal pro Woche) empfehlen wir einen Wechsel alle 12 bis 18 Monate. Der größte Qualitätsverlust entsteht nicht durch sichtbare Schäden, sondern durch den Abbau der Schaumstoffkerns, der das Spielgefühl verändert.
</details>
<details>
<summary>Was kostet ein guter Padelschläger?</summary>
<!-- TODO: Preisklassen-Überblick -->
Gute Einstiegsschläger gibt es ab 50 Euro. Für Fortgeschrittene empfehlen wir 100200 Euro, für ambitionierte Spieler 200350 Euro. Über 400 Euro kostet nur das Pro-Segment, das für die meisten Freizeitspieler überdimensioniert ist.
</details>
<details>
<summary>Runder oder Diamant-Schläger — was ist besser?</summary>
<!-- TODO -->
Runde Schläger verzeihen mehr Fehlschläge und eignen sich für Anfänger und defensive Spieler. Diamant-Schläger liefern mehr Power und werden von Angriffsspielern bevorzugt. Für die meisten Freizeitspieler ist eine Tropfen- oder runde Form die sicherere Wahl.
</details>

View File

@@ -0,0 +1,69 @@
---
title: "Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?"
slug: padel-ausruestung-anfaenger-de
language: de
url_path: /padel-ausruestung-anfaenger
meta_description: "Was braucht man für Padel? Unser Ausrüstungsguide für Einsteiger — von Schläger und Schuhen bis zur Schutztasche. Was ist unverzichtbar, was ist Luxus?"
---
# Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?
<!-- TODO: Einleitung — klare Orientierung für Einsteiger -->
Padel ist im Vergleich zu vielen anderen Sportarten günstig einzusteigen. Wer zum ersten Mal auf den Court geht, braucht eigentlich nur drei Dinge: einen Schläger, die richtigen Schuhe und Bälle. Der Rest ist komfortsteigerndes Zubehör — notwendig wird es erst, wenn man ernsthafter spielt.
---
## Die unverzichtbare Grundausstattung
### 1. Schläger
[product:platzhalter-anfaenger-schlaeger-amazon]
<!-- TODO: 12 Absätze zum Einstiegsschläger -->
### 2. Schuhe
[product:platzhalter-padelschuh-amazon]
<!-- TODO -->
### 3. Bälle
[product:platzhalter-ball-amazon]
<!-- TODO -->
---
## Was kann ich mir zunächst sparen?
<!-- TODO: Schläger-Tasche, Griffband, Sportbrille — wann sinnvoll? -->
---
## Das komplette Anfänger-Set: Unsere Empfehlung
[product-group:accessory]
---
## Häufige Fragen
<details>
<summary>Wie viel kostet ein komplettes Padel-Starterpaket?</summary>
<!-- TODO -->
Für rund 150 Euro bekommt man einen soliden Anfängerschläger (6090 €), passende Padelschuhe (5070 €) und eine Dose Bälle (610 €). Alles darüber hinaus ist optional.
</details>
<details>
<summary>Kann ich mit geliehener Ausrüstung starten?</summary>
<!-- TODO -->
Ja, für die ersten Einheiten ist das sinnvoll. Die meisten Padel-Center verleihen Schläger für 25 Euro pro Einheit. Wer mehr als 34 Mal spielen will, lohnt sich ein eigener Schläger — schon allein wegen des vertrauten Spielgefühls.
</details>

View File

@@ -0,0 +1,169 @@
---
title: "Was deutsche Banken wirklich im Padel-Businessplan sehen wollen"
slug: padel-business-plan-bank
language: de
url_path: /de/blog/padel-business-plan-bank
meta_description: "Kapitaldienstdeckungsgrad 1,21,5x, KfW-Förderprogramme, Covenant-Compliance: Was Banken und die KfW in einem Padel-Businessplan erwarten."
cornerstone: C3
---
# Was deutsche Banken wirklich im Padel-Businessplan sehen wollen
Die meisten abgelehnten Finanzierungsanfragen für Padelhallen scheitern nicht daran, dass das Projekt schlecht ist. Sie scheitern daran, dass der Businessplan Fragen offen lässt, die jeder Firmenkundenbetreuer stellt — und die sich mit ein bisschen Vorbereitung alle beantworten lassen. Wer mit einer Volksbank, Sparkasse oder Hausbank ins Erstgespräch geht, muss wissen, was auf der anderen Seite des Tisches erwartet wird. Dieser Artikel erklärt es.
---
## Was Banken wirklich wollen: Der Kapitaldienstdeckungsgrad
Bevor es um Gliederungspunkte geht, ein kurzer Ausflug in die Kreditperspektive: Banken vergeben keine Förderkredite aus Wohlwollen. Sie kalkulieren Ausfallrisiken. Das zentrale Instrument dabei ist der **Kapitaldienstdeckungsgrad (KDDB)** — im internationalen Kontext als DSCR (Debt Service Coverage Ratio) bekannt.
Die Formel ist einfach: Wie viel Cashflow steht nach Kosten zur Verfügung, um Zins und Tilgung zu bedienen?
```
KDDB = operativer Cashflow ÷ jährlicher Kapitaldienst (Zins + Tilgung)
```
Der Standard im deutschen Mittelstandskreditgeschäft: **1,2 bis 1,5x**. Das bedeutet: Für jeden Euro Kapitaldienst muss das Projekt 1,20 bis 1,50 Euro Cashflow erwirtschaften. Liegt der Wert unter 1,2 — entweder weil die Projektionen zu knapp kalkuliert sind oder weil zu wenig Eigenkapital eingebracht wird — ist die Absage in der Regel programmiert, es sei denn, es wird mehr Eigenkapital nachgeschossen.
**Was das für den Businessplan bedeutet:** Die Rentabilitätsvorschau und die Liquiditätsplanung müssen so aufgebaut sein, dass der Betreuer den KDDB auf einem Blick nachrechnen kann. Wer das nicht transparent macht, zwingt den Betreuer, selbst zu rechnen — und er rechnet dann konservativer als Sie.
Hinzu kommt die **Eigenkapitalquote**: Banken erwarten in aller Regel eine Eigenbeteiligung von mindestens 20 bis 30 Prozent der Gesamtinvestition. KfW-Förderprogramme können einen Teil des Eigenkapitals ersetzen (dazu unten mehr), aber sie ersetzen es nie vollständig. Wer mit 10 Prozent Eigenkapital an den Tisch kommt, wird selten Erfolg haben.
---
## Die vollständige Gliederung eines Padel-Businessplans
Banken arbeiten mit einer klaren inneren Checkliste. Wer den Businessplan so aufbaut, dass jeder Punkt abgehakt werden kann, erleichtert die Kreditentscheidung erheblich. Hier die vollständige Gliederung für ein Padelhallen-Projekt nach dem KfW-Gründerkredit-Standard:
### 1. Gründer- und Managementprofil
Wer sind Sie, und warum sind Sie die richtige Person für dieses Projekt? Banken finanzieren Menschen, nicht nur Konzepte. Relevante Erfahrung aus dem Sport-, Gastronomie- oder Facility-Management-Bereich stärkt die Glaubwürdigkeit erheblich. Lücken im Managementteam — etwa wenn niemand kaufmännische Erfahrung mitbringt — sind rote Flaggen, die adressiert werden müssen, zum Beispiel durch einen erfahrenen Steuerberater als externer Berater oder einen Co-Gründer mit entsprechendem Hintergrund.
### 2. Vorhabensbeschreibung
Konkret und spezifisch: Wo genau entsteht die Halle? Wie viele Courts (Indoor, Outdoor, oder beides)? Was ist das geplante Eröffnungsdatum? Was ist die Zielgruppe — Breitensport, Mitglieder, Turnierbetrieb? Vage Beschreibungen ("eine moderne Padel-Anlage im Großraum München") signalisieren, dass die Planung noch nicht ausgereift ist.
### 3. Marktanalyse
Hier scheitern überraschend viele Businesspläne — nicht weil die Analyse fehlt, sondern weil sie zu generisch ist. "Padel ist der am schnellsten wachsende Sport Europas" interessiert einen Kreditbetreuer herzlich wenig. Was ihn interessiert: Welche Padelhallen gibt es im Einzugsgebiet (15-Minuten-Fahrzeit)? Wie sind deren Auslastungsgrade? Gibt es ungedeckte Nachfrage? Die Marktanalyse muss lokal und konkret sein.
### 4. Leistungsangebot
Was genau verkaufen Sie, und zu welchen Preisen? Court-Vermietung (Preismodell: Stoßzeiten vs. Off-Peak, Einzelstunde vs. Abo), Coaching-Programme, Food & Beverage, Merchandise. Für jeden Umsatzstrom muss die Preisgestaltung und die Umsatzerwartung plausibel hergeleitet werden.
### 5. Marketingkonzept
Wie füllen Sie die Courts? Ein plausibles Pre-Opening-Konzept (Vorverkauf, Gründungsrabatte, lokale Kooperationen) und ein laufendes Marketingbudget sind Pflicht. Banken wissen, dass Auslastung nicht von selbst kommt — wer keinen Vermarktungsplan hat, wird die Projektionen nicht erreichen.
### 6. Betriebskonzept
Stellenplan (wie viele Vollzeitstellen, welche Funktionen), Öffnungszeiten, Buchungssystem, Wartungsplan. Der Personalkostenblock ist oft der größte laufende Kostenblock — er muss plausibel und vollständig sein.
### 7. Investitionsplan (CAPEX)
Banken erwarten keine Schätzungen, sondern Positionen: Rohbau, Hallenstruktur, Court-Belag, Beleuchtung (LED-Standard für Padel ist energie- und kostenintensiv), Buchungssystem, Einrichtung Umkleiden und Lounge, Baunebenkosten, Notarkosten, Maklerkosten. Idealerweise belegt durch Angebote. "Baukosten gesamt: 600.000 Euro" ohne Aufschlüsselung ist kein Investitionsplan.
### 8. Mittelverwendungsplan
Wo fließt jeder Euro des Kredits hin? Dieser Plan schlägt die Brücke zwischen Investitionsplan und Finanzierungsplan. Er muss auf einzelne CAPEX-Positionen verweisen.
### 9. Finanzierungsplan
Wie ist die Finanzierung strukturiert? Eigenkapital (Betrag, Quelle), Förderkredite (KfW, Landesbank), Bankdarlehen, Gesellschafterdarlehen. Und: Welche KfW-Programme wurden geprüft? Wer KfW hier nicht erwähnt, signalisiert mangelnde Vorbereitung.
### 10. Rentabilitätsvorschau (GuV-Planung)
Fünf-Jahres-Projektion mit monatlicher Auflösung für Jahr 1. Umsatzannahmen müssen explizit hergeleitet sein: Anzahl Courts × Buchungsstunden × Auslastungsgrad × Preis. Separat für jeden Umsatzstrom. Kostenblöcke müssen vollständig sein (Miete, Personal, Energie, Versicherungen, Marketing, Instandhaltungsrücklage, Abschreibungen, Zinsen, Tilgung).
### 11. Liquiditätsplanung (Cashflow)
Monatsgenaue Cashflow-Planung für Jahr 1, quartalsweise für Jahr 23. Besonderes Augenmerk auf die Vorbereitungsphase: Wann laufen Mietverbindlichkeiten auf? Wann beginnt der Umsatz? Das Liquiditätsminimum vor Eröffnung ist oft das Risiko, das Banken am meisten beschäftigt.
### 12. Risikoanalyse
Drei Szenarien mindestens: Basisfall, konservativer Fall (1015% geringere Auslastung, 10% höhere Baukosten), Worst Case. Was sind die Risikotreiber, und was sind die Gegenmaßnahmen? Ein Plan ohne Risikoanalyse wirkt naiv — und lässt Banken selbst die schlimmsten Szenarien durchrechnen.
### 13. Eröffnungsbilanz
Die Bilanz am ersten Betriebstag: Aktiva (Anlagevermögen nach CAPEX, Anfangsliquidität) versus Passiva (Eigenkapital, Darlehensverbindlichkeiten). Sie zeigt, ob die Finanzierungsstruktur rechnerisch aufgeht.
---
## KfW-Förderprogramme für Padelhallen
Abschnitt 9 des Gliederungsrahmens verlangt: Welche Förderprogramme wurden geprüft? Hier ist die Antwort, die Ihr Businessplan liefern muss.
Die KfW bietet mehrere Programme, die für Padelhallen-Projekte relevant sein können. Wichtig: KfW-Kredite werden nicht direkt bei der KfW beantragt, sondern über die Hausbank. Die Hausbank leitet den Antrag weiter und trägt einen Teil des Ausfallrisikos mit — was erklärt, warum sie ein starkes Eigeninteresse an der Qualität des Businessplans hat.
**KfW Unternehmerkredit (037/047)**
Das klassische Investitionsprogramm für etablierte Unternehmen. Finanzierungsvolumen bis 25 Millionen Euro, bis zu 100 Prozent der förderfähigen Investitionskosten. Besonders geeignet, wenn ein bestehendes Unternehmen (z.B. ein bestehender Sportbetrieb) die Padelhalle als neue Einheit aufbaut.
**ERP-Kapital für Gründung (058)**
Bis zu 500.000 Euro nachrangiges Kapital für Unternehmensgründungen. Das Besondere: Es wird bilanziell wie Eigenkapital behandelt und verbessert so die Eigenkapitalquote für weitere Bankfinanzierungen. Für Neugründungen besonders attraktiv.
**KfW-Gründerkredit StartGeld (067)**
Bis 125.000 Euro für Kleinstgründungen. Für den Bau einer vollwertigen Padelhalle meist zu klein, aber relevant für die frühe Planungsphase oder als ergänzende Finanzierung für Gründer ohne größere Eigenmittel.
**Landesspezifische Programme**
Jedes Bundesland hat eigene Förderprogramme, die KfW-Mittel ergänzen können:
- NRW: NRW.BANK mit eigenen Gründungs- und Investitionsprogrammen
- Bayern: Bayern Kapital und LfA Förderbank Bayern
- Berlin: Investitionsbank Berlin (IBB)
- Weitere: L-Bank (BW), IFB Hamburg, SAB (Sachsen), etc.
Diese Programme werden in zu vielen Businessplänen schlicht ignoriert — obwohl ihre Kombination mit KfW-Mitteln die Eigenkapitalanforderungen erheblich reduzieren kann.
---
## Die fünf häufigsten Fehler im Padel-Businessplan
### 1. Generische Marktanalyse statt lokalem Wettbewerbsbild
"Der Padel-Markt wächst in Europa um X Prozent pro Jahr" ist kein Argument für eine Finanzierung in Augsburg. Was zählt: Wie viele Courts gibt es im Einzugsgebiet? Welche Auslastung haben sie? Gibt es eine Nachfragelücke — oder ist der Markt schon versorgt?
### 2. Unplausible Auslastungsannahmen
70 oder 80 Prozent Auslastung ab dem ersten Betriebsmonat — das sieht man in Businessplänen regelmäßig. Kreditbetreuer sehen es auch regelmäßig, und sie disqualifizieren es sofort. Realistisch ist ein Hochlauf: Jahr 1 mit 4050 Prozent, Jahr 2 mit 5565 Prozent, Vollbetrieb ab Jahr 3. Wer niedrige Anfangsauslastung plant, beweist, dass er das operative Risiko versteht.
### 3. Keine Sensitivitätsanalyse
Was passiert, wenn die Auslastung 10 Prozentpunkte unter Plan liegt? Wenn die Baukosten 20 Prozent überziehen? Wenn ein Court-Belag nach zwei Jahren ausgetauscht werden muss? Diese Fragen werden im Bankgespräch gestellt. Wer die Antwort nicht vorbereitet hat, improvisiert — und das ist selten überzeugend.
### 4. Unvollständiger CAPEX
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (36 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Mindestpuffer: 10 Prozent auf den Rohbau — bei Sportstättenumbauten realistisch eher 1520 Prozent). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
### 5. KfW nicht adressiert
Ein Businessplan ohne Auseinandersetzung mit den verfügbaren Förderprogrammen signalisiert: Entweder hat der Gründer die Hausaufgaben nicht gemacht, oder er hat nachgerechnet und es lohnt sich nicht (was dann erklärungsbedürftig ist). Beides ist keine gute Ausgangsposition.
---
## Persönliche Bürgschaft: Was das wirklich bedeutet
Wer eine Padelhalle als Einzelstandort finanziert, wird eine persönliche Bürgschaft unterzeichnen. Das ist keine Formalie. Es bedeutet: Scheitert das Projekt, haftet der Gründer mit seinem Privatvermögen — Ersparnisse, Immobilien, Rentenansprüche je nach Struktur.
In Businessplänen wird dieser Punkt häufig weggelassen oder in einem Satz abgehandelt. Das ist ein Fehler — nicht weil die Bank den Hinweis braucht (sie weiß es), sondern weil Sie als Gründer die Konsequenz durchdrungen haben sollten, bevor Sie unterschreiben.
Fragen, die Sie sich vor der Bürgschaftsübernahme stellen sollten:
- Wie hoch ist mein persönliches Nettovermögen, das ich im Notfall einsetzen kann?
- Gibt es Vermögenswerte, die ich herauslösen kann (z.B. durch Schenkung an Ehepartner vor Gründung — hier unbedingt Rechtsberatung einholen, da Anfechtungsrisiken bestehen)?
- Wie viele Monate Verlustbetrieb kann ich aus eigenen Mitteln abfedern?
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen. Das spüren Banken.
---
## Wie Padelnomics hilft
Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten im Hintergrund. Padelnomics generiert aus Ihrem Finanzmodell eine vollständige Rentabilitätsvorschau, Liquiditätsplanung und Sensitivitätsanalyse — formatiert nach dem Standard, den deutsche Hausbanken und KfW-Bearbeiter erwarten. Kein generisches Excel-Template, sondern Zahlen, die zu Ihrer spezifischen Anlage passen: Anzahl Courts, Standortmiete, geplante Eröffnung, lokale Marktdaten.
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
[→ Businessplan erstellen](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,169 @@
---
title: "What German Banks Really Want to See in a Padel Hall Business Plan"
slug: padel-business-plan-bank-requirements
language: en
url_path: /en/blog/padel-business-plan-bank-requirements
meta_description: "DSCR 1.21.5x, KfW programs, covenant compliance: what banks expect from a padel hall business plan in Germany, from people who've reviewed them."
cornerstone: C3
---
# What German Banks Really Want to See in a Padel Hall Business Plan
Most rejected financing applications for padel halls don't fail because the project is bad. They fail because the business plan leaves questions open that every commercial loan officer will ask — questions that are entirely answerable with proper preparation. If you're walking into a first meeting with a Volksbank, Sparkasse, or any German regional bank, you need to know what's expected on the other side of the table. This article covers it.
---
## The Number That Determines Everything: DSCR
Before we get to document structure, a brief detour into how banks think. Banks don't lend out of enthusiasm for padel's growth trajectory. They model default risk. The central instrument is the **Debt Service Coverage Ratio (DSCR)** — in German: *Kapitaldienstdeckungsgrad (KDDB)*.
The formula:
```
DSCR = operating cash flow ÷ annual debt service (interest + principal)
```
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.201.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and they'll be more conservative than you.
The other hard constraint is **equity contribution** (*Eigenkapitalquote*): banks typically expect the founder to put in 2030% of total investment. KfW subsidy programs can partly substitute for equity (more on that below), but they never replace it entirely. Coming to the table with 10% equity rarely works.
---
## The 13 Sections of a German Padel Hall Business Plan
German banks work from a mental checklist. Structure your plan so each item gets checked off clearly, and you reduce friction in the credit decision considerably. Here's the full framework, based on the KfW *Gründerkredit* standard:
### 1. Founder and Management Profile (*Gründer- und Managementprofil*)
Who are you, and why are you the right person for this project? Banks finance people as much as they finance concepts. Relevant experience in sports operations, hospitality, or facility management carries real weight. Gaps in the management team — say, nobody with commercial or P&L experience — are red flags that need to be addressed directly, whether through a qualified co-founder, an experienced external advisor, or a committed board member.
### 2. Project Description (*Vorhabensbeschreibung*)
Specific and concrete: Where exactly is the facility? How many courts — indoor, outdoor, or both? Target opening date? Who is the target customer — recreational players, members, competitive players? Vague descriptions ("a modern padel facility in greater Munich") signal that planning hasn't progressed far enough.
### 3. Market Analysis (*Marktanalyse*)
This is where many business plans fail — not because the section is missing, but because it's generic. "Padel is the fastest-growing sport in Europe" is not a financial argument. What matters: How many padel facilities exist within a 15-minute drive? What are their utilization rates? Is there unmet demand, or is the local market already served? The analysis must be local and specific. (More on how to research this in our location guide.)
### 4. Service Offering (*Leistungsangebot*)
What exactly are you selling, at what prices? Court rental pricing (peak vs. off-peak, hourly vs. subscription), coaching programs, food and beverage, memberships, merchandise. Every revenue stream needs a price point and a volume assumption, both of which need to be traceable back to comparable benchmarks.
### 5. Marketing Concept (*Marketingkonzept*)
How will courts get filled? A credible pre-opening plan (pre-sale memberships, launch discounts, local partnerships with sports clubs) and an ongoing marketing budget are not optional. Banks understand that utilization doesn't happen by itself. A plan without a marketing budget is a plan that won't hit its revenue projections.
### 6. Operating Concept (*Betriebskonzept*)
Staffing plan (how many FTE, what roles), operating hours, booking system, maintenance schedule. Payroll is typically the largest recurring cost line — it needs to be complete and defensible.
### 7. CAPEX Investment Plan (*Investitionsplan*)
Banks want line items, not totals. Construction costs broken down by trade, court surfacing, LED lighting (padel lighting is energy-intensive and expensive), booking system, locker room and lounge fit-out, ancillary construction costs, notary and permitting fees. Ideally supported by contractor quotes. "Total construction: €600k" is not an investment plan.
### 8. Use of Funds (*Mittelverwendungsplan*)
Where does every euro of the loan go? This bridges the gap between the CAPEX plan and the financing structure, mapping loan proceeds to specific investment line items.
### 9. Financing Structure (*Finanzierungsplan*)
How is the project financed? Equity (amount, source), KfW loans, bank loans, shareholder loans. And critically: which KfW programs have been evaluated? Failing to mention KfW signals you haven't done your homework.
### 10. P&L Projection (*Rentabilitätsvorschau*)
A five-year projection, with monthly detail for Year 1. Revenue assumptions must be explicitly derived: number of courts × bookable hours × utilization rate × price. Separately for each revenue stream. Cost lines must be complete: rent, payroll, energy, insurance, marketing, maintenance reserve, depreciation, interest, principal repayment.
### 11. Cash Flow Plan (*Liquiditätsplanung*)
Month-by-month cash flow for Year 1, quarterly for Years 23. Special attention to the pre-opening period: when do lease obligations start running? When does revenue begin? The cash trough before opening is often the risk that concerns banks most.
### 12. Risk Analysis (*Risikoanalyse*)
Three scenarios minimum: base case, conservative case (1015% lower utilization, 10% construction overrun), and a stress case. What are the risk drivers, and what are the mitigations? A plan without scenario analysis looks naive — and forces the loan officer to imagine the worst.
### 13. Opening Balance Sheet (*Eröffnungsbilanz*)
The balance sheet on Day 1: assets (fixed assets after CAPEX, opening cash) versus liabilities (equity, loan balances). It demonstrates that the financing structure is arithmetically coherent.
---
## KfW Subsidy Programs for Padel Hall Projects
Section 9 of the business plan framework above asks which financing programs have been evaluated. Here's the answer your plan needs to provide.
KfW (Germany's state development bank) offers several programs relevant to padel hall construction and launch. One crucial operational detail: KfW loans are not applied for directly at KfW. They're applied for through your *Hausbank* (house bank), which passes the application to KfW and shares a portion of the default risk. This is precisely why your Hausbank cares so much about the quality of your business plan — they're on the hook too.
**KfW Unternehmerkredit (programs 037/047)**
The core investment program for established businesses. Financing up to €25 million, covering up to 100% of eligible investment costs. Most relevant if an existing company (e.g., an existing sports facility operator) is adding padel as a new business unit.
**ERP-Kapital für Gründung (program 058)**
Up to €500k in subordinated capital for startups and young companies. The key feature: it counts as equity on your balance sheet, improving your *Eigenkapitalquote* and making you more bankable for additional loan facilities. Highly attractive for new-build projects.
**KfW-Gründerkredit StartGeld (program 067)**
Up to €125k for micro-entrepreneurs. Usually too small for a full padel hall build, but can supplement a larger financing package or cover early-stage feasibility costs.
**Federal state programs (*Landesförderung*)**
Each German state (*Bundesland*) runs its own SME and startup lending programs that can be layered on top of KfW:
- North Rhine-Westphalia: NRW.BANK
- Bavaria: LfA Förderbank Bayern, Bayern Kapital
- Berlin: Investitionsbank Berlin (IBB)
- Baden-Württemberg: L-Bank
- Hamburg: IFB Hamburg
- Saxony: Sächsische Aufbaubank (SAB)
These programs are overlooked in the majority of business plans we've reviewed — even though combining them with KfW can meaningfully reduce the equity burden.
---
## The Five Most Common Weaknesses in Padel Hall Business Plans
### 1. Generic market analysis
"Padel is growing rapidly across Europe" does not justify a loan in Augsburg. What matters: How many courts are within a 15-minute drive? What are their utilization rates? Is there an identifiable demand gap, or has the local market already been addressed?
### 2. Implausible utilization assumptions
70% or 80% utilization from Month 1 appears in business plans with surprising regularity. Loan officers see it regularly too, and they discount it immediately. What's credible: a ramp-up trajectory — Year 1 at 4050%, Year 2 at 5565%, steady state from Year 3. Modeling a realistic ramp-up demonstrates that you understand operational risk.
### 3. No sensitivity analysis
What happens if utilization comes in 10 percentage points below plan? If construction overruns by 20%? If a court surface needs replacement after two years? These questions will be asked in the bank meeting. Having the answers prepared — ideally already modeled — is the difference between a confident conversation and an improvised one.
### 4. Incomplete CAPEX
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (36 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (minimum 10% of raw construction costs — 1520% is more realistic for sports hall conversions). Forget these, and you're underfunded from Day 1.
### 5. No mention of KfW or subsidy programs
A business plan that doesn't engage with available subsidy programs sends one of two signals: either the founder hasn't done their homework, or they've investigated and found it doesn't work for their project (which itself requires explanation). Neither is a strong opening position.
---
## Personal Guarantees: What This Actually Means
For a single-site padel facility, banks will require a personal guarantee (*persönliche Bürgschaft*) from the founders. This is not a formality. It means: if the project fails, the founder's personal assets — savings, property, retirement provisions depending on structure — are exposed.
Business plans typically gloss over this point or omit it entirely. That's a mistake — not because the bank needs the reminder (they know), but because founders should have thought through the implications before signing.
Questions worth answering before you proceed:
- What is my personal net worth that could theoretically be drawn upon?
- Are there assets that could be structured outside the exposure (specialist legal advice is essential here, as pre-signing asset transfers can be challenged under German insolvency law)?
- How many months of operating losses could I absorb from personal resources?
A founder who has worked through these questions has taken the project seriously. Banks can tell.
---
## How Padelnomics Helps
A bankable business plan depends on the quality of the financial model behind it. Padelnomics generates a complete P&L projection, cash flow plan, and sensitivity analysis from your facility parameters — formatted to the standard that German house banks and KfW processors expect. Not a generic template, but numbers calibrated to your specific project: number of courts, location rent, planned opening date, local market data.
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
[→ Generate your business plan](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,67 @@
---
title: "Padel-Geschenke: Die besten Ideen für Padelbegeisterte"
slug: padel-geschenke-de
language: de
url_path: /padel-geschenke
meta_description: "Padel-Geschenke für Geburtstage, Weihnachten oder als Überraschung. Von der günstigen Kleinigkeit bis zum hochwertigen Schläger — für jeden Budget."
---
# Padel-Geschenke: Die besten Ideen für Padelbegeisterte
<!-- TODO: Einleitung — Padel boomt, Geschenkideen gefragt -->
Padel ist der am schnellsten wachsende Sport Europas — und viele haben gerade erst damit begonnen. Wer einem Padel-Fan ein Geschenk machen will, steht vor der Frage: Was fehlt ihm noch? Dieser Guide listet die besten Ideen nach Preisklassen, vom kleinen Mitbringsel bis zum Wunschschläger.
---
## Geschenke unter 15 Euro
[product-group:grip]
<!-- TODO: Griffband, Bälle, kleine Accessoires -->
---
## Geschenke unter 50 Euro
[product-group:accessory]
<!-- TODO: Sporttasche, Cover, Trainingszubehör -->
---
## Geschenke unter 100 Euro
<!-- TODO -->
[product:platzhalter-schuh-amazon]
---
## Das perfekte Geschenk: Ein neuer Schläger
[product-group:racket]
<!-- TODO: Hinweis auf Wunschliste / Amazon-Wunschliste-Tipp -->
---
## Häufige Fragen
<details>
<summary>Wie finde ich heraus, welcher Schläger passt?</summary>
<!-- TODO -->
Fragen Sie die beschenkte Person nach ihrem aktuellen Modell oder lassen Sie sie aus einer Empfehlungsliste wählen. Schläger sind sehr persönlich — eine Gutscheinkarte für einen Fachhandel ist oft die sicherste Option.
</details>
<details>
<summary>Gibt es Padel-Geschenksets?</summary>
<!-- TODO -->
Einige Marken bieten Starter-Sets an (Schläger + Bälle + Cover). Diese sind im Vergleich zum Einzelkauf oft günstiger und eignen sich als Komplett-Einstiegsgeschenk für Neuspieler.
</details>

View File

@@ -0,0 +1,340 @@
---
title: "How to Build a Padel Hall: The 5-Phase Process from Feasibility to Opening Day"
slug: padel-hall-build-guide
language: en
url_path: /padel-hall-build-guide
meta_description: "Complete guide to building a padel hall. All 23 steps across feasibility, design, construction, pre-opening, and operations. Realistic timelines and what to watch out for."
cornerstone: C8
---
# How to Build a Padel Hall: The 5-Phase Process from Feasibility to Opening Day
The realistic timeline from first concept to opening day is 12 to 18 months. Operators who plan for 9 months almost always run late. Those who budget 18 months negotiate better, handle surprises better, and open with less stress.
This guide walks through all five phases and 23 steps between your initial market research and a running facility. No glossy success stories — a practical account of what actually happens, in what order, and where things commonly go wrong.
---
## The 5 Phases at a Glance
<div class="article-timeline">
<div class="article-timeline__phase">
<div class="article-timeline__num">1</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Feasibility &amp; Concept</div>
<div class="article-timeline__subtitle">Market research, concept, site scouting</div>
<div class="article-timeline__meta">Month 13 · Steps 15</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">2</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Planning &amp; Design</div>
<div class="article-timeline__subtitle">Architect, permits, financing</div>
<div class="article-timeline__meta">Month 36 · Steps 611</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">3</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Construction</div>
<div class="article-timeline__subtitle">Build, courts, IT systems</div>
<div class="article-timeline__meta">Month 612 · Steps 1216</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">4</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Pre-Opening</div>
<div class="article-timeline__subtitle">Hiring, marketing, soft launch</div>
<div class="article-timeline__meta">Month 1013 · Steps 1720</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">5</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Operations</div>
<div class="article-timeline__subtitle">Revenue streams, optimization</div>
<div class="article-timeline__meta">Ongoing · Steps 2123</div>
</div>
</div>
</div>
---
## Phase 1: Feasibility and Concept (Months 13)
This is the most important phase — and where projects most often go wrong in one of two directions: stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
### Step 1: Market Research
Before you look at a single site or open a spreadsheet, you need to understand whether your target market can support the facility you're planning.
That means:
- **Player demand:** How many active padel players exist within a 1520 minute drive? How full are existing facilities? What are waitlist lengths? These are the leading indicators of unmet demand.
- **Competitive mapping:** Which facilities exist, which are planned? Court counts, pricing, utilization, service level. Planning applications are often public record — check them.
- **Demographics:** Where do your target customers actually live and work — working professionals aged 2555, companies with wellness budgets, sports clubs needing training facilities? Do they concentrate within the catchment area of your proposed site?
Good market research won't guarantee success, but it will protect you from the most common mistake: building the right facility in the wrong location.
### Step 2: Concept Development
Your market research should drive your concept. How many courts? Which customer segments — competitive recreational players, club training, corporate wellness, broad community use? What service level — a pure booking facility or a full-concept venue with lounge, bar, pro shop, and coaching program?
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail the concept before moving to site selection.
### Step 3: Location Scouting
Evaluate three to five candidate sites in parallel. Assess each against:
| Criterion | What to Check |
|-----------|---------------|
| Accessibility | Public transport, parking, cycling infrastructure |
| Visibility | Foot traffic, street presence, signage options |
| Floor area | Net usable area for courts plus ancillary spaces (changing rooms, reception, lounge) |
| Clear height | Minimum 8 meters for indoor courts — 10+ for competition use |
| Zoning | Is sports facility use permitted? Noise restrictions? Change-of-use requirements? |
| Rent | Monthly lease cost relative to projected revenue |
A site that scores 70% across all dimensions is almost always better than one that excels on a single criterion while failing on two others.
### Step 4: Preliminary Financial Model
At this stage you don't need a full financial model. You need a viability check.
Rough questions to answer:
- Does the total investment (construction, courts, fit-out, contingency) fit within your available capital plus realistic debt capacity?
- What utilization rate do you need to break even on operating costs? Is that achievable in your market?
- Does the model still work at conservative assumptions — 50% utilization, not 70%?
If the business only works under optimistic assumptions, that's a signal to stress-test the concept, not to adjust the assumptions until they fit.
### Step 5: Go / No-Go Decision
Phase 1 ends with a real decision. Not "let's keep going and see" — a reasoned answer to the question: do market, location, and preliminary financials together justify the substantially higher costs of Phase 2?
If yes: proceed. If no, or if material questions remain open: more analysis or a deliberate stop.
---
## Phase 2: Planning and Design (Months 36)
The project becomes concrete in this phase. External advisors, architects, and lawyers come on board. Costs increase meaningfully. The point of no return approaches.
### Step 6: Secure the Site
Sign a letter of intent or option agreement for your preferred site. This gives you an exclusive negotiation window without full contractual commitment.
Don't sign the final lease until the design concept is established — you need to know what you're actually leasing and whether the site can support your facility as designed. Lease terms are negotiated now, not after signing.
### Step 7: Appoint an Architect and Specialist Engineers
Hire an architect with demonstrated experience in sports facilities or industrial-to-sports conversions — not a generalist with a good portfolio, but someone who understands what padel courts require structurally and mechanically.
Deliverables from this phase:
- **Floor plans and spatial layout:** Court configuration, circulation, changing rooms, reception, plant rooms
- **Structural assessment:** Is the existing structure suitable for courts and any elevated seating?
- **MEP design (mechanical, electrical, plumbing):** Heating, ventilation, air conditioning, electrical, drainage — typically the most expensive trade package in a sports hall conversion
- **Fire safety strategy**
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">The most expensive planning mistake in padel hall builds</span>
<p>Underestimating HVAC complexity and budget. Large indoor courts need precise temperature and humidity control — not just for player comfort, but for playing surface longevity and air quality. Courts installed in a poorly climate-controlled building will degrade faster and generate complaints. Budget for it properly from the start, not as a value-engineering target.</p>
</div>
</div>
### Step 8: Court Supplier Selection
Get quotes from at least three court manufacturers. European suppliers vary in specification, warranty terms, and delivery capability — evaluate all three dimensions, not just price.
Coordinate technical requirements between the manufacturer and your architect from the outset: court dimensions, drainage specifications, lighting requirements (lux levels vary by playing standard), glass specifications, and foundation construction requirements.
This coordination needs to happen in Phase 2, not Phase 3. Conflicts discovered during construction between manufacturer specs and building design generate costly change orders.
### Step 9: Detailed Financial Model
With real lease costs, architectural estimates, and court quotes in hand, build the full model. Refine all assumptions and run explicit sensitivity analysis — at minimum across utilization (±15 percentage points) and construction costs (+20%). These aren't stress tests for show; they're the scenarios you should actually be planning for.
### Step 10: Secure Financing
Approach lenders with your full business plan. Typical capital structure for padel hall projects:
- 5070% debt (bank loan)
- 3050% equity (own funds, silent partners, shareholder loans)
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. The companion article on investment risks covers personal guarantee exposure in full.
Investigate public funding programs: development bank loans, regional sports infrastructure grants, and municipal co-investment schemes can reduce either equity requirements or interest burden. This research is worth several hours of your time.
### Step 11: Planning Permissions and Regulatory Approvals
Typically required: building permit (change-of-use application if the building isn't already zoned as a sports facility), noise impact assessment, possibly environmental review.
Budget four to six months for this step depending on the local authority and project complexity. The single best thing you can do to protect your timeline is to have informal pre-application conversations with the relevant authority before submitting. Find out what they'll ask for and address it upfront.
---
## Phase 3: Construction and Conversion (Months 612)
The most capital-intensive and schedule-sensitive phase. This is where budget and timeline either hold or don't.
### Step 12: Tender, Contract, and Mobilize
Have your architect prepare detailed specifications and tender the main trade packages. Decide whether to appoint a general contractor (single point of responsibility, cost premium) or to manage trades directly (lower cost, significantly higher management burden).
Key trades in a sports hall build or conversion:
- **Structural / civil:** If structural modifications are required
- **Ground works:** Court foundations and drainage — often the first significant milestone
- **HVAC:** Heating, ventilation, air conditioning — typically 2025% of total construction cost
- **Electrical:** LED court lighting to lux standard, distribution boards, emergency systems
- **Plumbing:** Changing rooms, showers, bar if applicable
Negotiate fixed-price contracts where you can. Read the risk allocation provisions in every contract — not just the summary price.
### Step 13: Court Installation
Courts are installed after the building envelope is weathertight. This is a hard sequencing rule, not a suggestion.
Glass panels, artificial turf, and court metalwork must not be exposed to construction dust, moisture, and site traffic. Projects that try to accelerate schedules by installing courts before the building is properly enclosed regularly end up with surface contamination, glass damage, and voided manufacturer warranties.
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">The most common construction mistake on padel hall projects</span>
<p>Rushing court installation sequencing under schedule pressure. The pressure to hit an opening date is real — but installing courts into an unenclosed building is one of the most reliable ways to add cost and delay, not reduce them. Hold the sequence.</p>
</div>
</div>
Allow two to four weeks for court installation per batch, depending on the manufacturer's crew capacity. Build this explicitly into your master program.
### Step 14: Fit-Out of Ancillary Areas
Reception desk, changing rooms and showers, lounge area, bar setup, pro shop fixtures. These spaces make the first impression on every visitor and should not be treated as afterthoughts. Budget, specification, and timeline for ancillary fit-out belong in your main construction program, not as a separate appendix.
### Step 15: IT Infrastructure, Booking System, and Access Control
Decide early: which booking platform, which point-of-sale system, and whether you want automated access control? System configuration — setting up courts, defining pricing rules, configuring memberships, integrating payments — takes longer than expected.
Access control systems must be coordinated with the electrical design. Adding them in the final stages of construction is possible but costs more.
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">The most common pre-opening mistake</span>
<p>The booking system isn't fully configured, tested, and working on day one. A broken booking flow, failed test payments, or a QR code that leads to an error page on opening day kills your launch momentum in a way that's difficult to recover from. Test the system end-to-end — including real bookings, real payments, and real cancellations — two to four weeks before opening.</p>
</div>
</div>
### Step 16: Inspections and Certifications
Fire safety sign-off, building control completion certificate, operating license where required, accessibility compliance. Allow four to eight weeks before your target opening date for this step. Do not schedule your opening event until at least the fire safety inspection is confirmed.
---
## Phase 4: Pre-Opening (Months 1013)
The building is ready. Now you determine whether you have customers on day one — or whether you're waiting for the first booking in an empty hall.
### Step 17: Hire Your Team
Start recruiting three to four months before the planned opening. Last-minute hiring gets you whoever is still available.
Core opening team:
- **Facility manager:** Operational accountability, booking management, customer relationships — the most important hire and the hardest to get right. Don't compromise here.
- **Reception / front of house:** For peak times — weekday evenings and full weekend days
- **Coaches:** If coaching programs are in scope, quality over quantity. One excellent coach with an established following is worth three average ones.
- **Cleaning:** Regular court maintenance is not a secondary concern. Dirty courts generate reviews. Clean courts don't, which is exactly what you want.
### Step 18: Pre-Launch Marketing
Don't wait for opening day to become known. Build your community in advance:
- Social media construction updates generate local awareness and genuine anticipation
- Local partnerships: sports clubs, companies with wellness budgets, nearby fitness operators
- Press outreach: local media covers new sports infrastructure willingly — but only if you approach them with a clear story before the opening, not after
- Founding member offers or introductory pricing: these create an early customer base and stabilize early-stage utilization, which is the hardest period in any new venue's life
### Step 19: Soft Opening
Before the public launch, invite a curated group: local padel players, micro-influencers with relevant audiences, sports journalists, potential corporate clients. The goals are specific: real feedback on court quality and operational flow, early reviews, photographs and video of actual players in a working facility.
The soft opening is also your last opportunity to identify operational problems before normal operations begin. Find them now, not in week three.
### Step 20: Grand Opening
Celebrate it — but understand that opening day is the beginning of a long build, not its culmination. The operators who succeed long-term treat the opening as the start of their community-building program, not the end of their pre-opening marketing.
---
## Phase 5: Operations and Optimization (Ongoing)
Construction is finished. The real work starts now.
### Step 21: Monitor Utilization and Manage Pricing Dynamically
Not all time slots are equal. Monday evening at 8pm books out; Tuesday at 1pm runs at 15%. Dynamic pricing — lower rates during off-peak hours, premium pricing at high-demand slots — can materially improve overall utilization without acquiring a single new customer.
Measure by court, by day of week, by time slot. Which courts fill first? Which times consistently underperform? The answers are in the data your booking system generates daily. Use them.
### Step 22: Build the Community
A high-utilization padel venue isn't a booking machine — it's a social hub. Regular tournaments, recreational leagues, corporate events, beginner courses, themed evenings: these are the formats that convert first-time visitors into regulars.
Corporate clients are a consistently underestimated segment. Companies with employee wellness budgets actively want team activities and employee benefits — they just don't know your venue offers it. Direct outreach with a clear proposition (flat-rate group events, framework agreements for regular bookings) opens this channel efficiently.
### Step 23: Broaden Revenue Streams
Court bookings are your core revenue, but rarely your only opportunity:
- **Coaching:** Qualified coaches with existing client bases are a real revenue lever when the compensation model is structured well
- **Equipment:** Racket rental and retail, balls, accessories — low capital requirement, reasonable margin
- **Food and beverage:** If you do this, do it properly or outsource it to a dedicated operator. A mediocre café doesn't just underperform — it actively degrades the overall venue impression.
- **Memberships:** Monthly packages with guaranteed booking allowances stabilize cash flow and build medium-term customer retention
---
## What Separates Successful Builds from the Ones That Overshoot
Patterns emerge when you observe padel hall projects across a market over time.
<div class="article-cards">
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projects that go over budget</span>
<p class="article-card__body">Almost always cut at the wrong place early — too little HVAC budget, no construction contingency, a cheap general contractor without adequate contractual protection. The savings on the way in become much larger costs on the way out.</p>
</div>
</div>
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projects that slip their schedule</span>
<p class="article-card__body">Consistently underestimate the regulatory process. Permits, noise assessments, and change-of-use applications take time that money cannot buy once you've started too late. Start conversations with authorities before you need the approvals.</p>
</div>
</div>
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projects that open weakly</span>
<p class="article-card__body">Started marketing too late and tested the booking system too late. An empty calendar on day one and a broken booking page create impressions that stick longer than the opening week.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projects that succeed long-term</span>
<p class="article-card__body">Treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers.</p>
</div>
</div>
</div>
Building a padel hall is complex, but it is a solved problem. The failures are nearly always the same failures. So are the successes.
---
## Find the Right Build Partners
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,200 @@
---
title: "How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown"
slug: padel-hall-cost-guide
language: en
url_path: /padel-hall-cost-guide
meta_description: "Real cost data for opening a padel hall in Germany in 2026. Full CAPEX breakdown €930K€1.9M, city-by-city pricing, operating costs, and ROI model."
cornerstone: C2
---
# How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown
Anyone researching padel hall investment in Germany hits the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
But that range is not noise. It reflects specific, quantifiable decisions: whether you're fitting out an existing warehouse or building from scratch, whether you're in Munich or Leipzig, whether you want panorama glass courts or standard construction. Once you understand where the variance lives, the numbers become plannable.
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 20252026. By the end, you'll have everything you need to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
---
## Why the Cost Range Is So Wide
The single largest driver of CAPEX variance is construction. Converting a suitable existing warehouse — one that already has the necessary ceiling height (89 m clear) and adequate structural load — costs vastly less than a ground-up build or a complete gut-renovation. This line item alone accounts for €400,000 to €800,000 of the total budget.
Location adds another layer of variance. The same 2,000 sqm hall costs 4060% more to rent in Munich than in Leipzig across comparable market tiers — at the extremes, the gap is considerably wider. That difference runs through every budget line: not just annual rent, but the lease deposit and working capital reserve needed at launch, both part of your initial CAPEX.
For a **six-court indoor facility** with solid but not extravagant fit-out, the realistic planning figure is **€1.21.5 million all-in**. Projects that come in below that typically either benefited from an exceptional real estate deal or — more often — undercounted one of the three most expensive items: construction, HVAC, and the operating reserve.
---
## Complete CAPEX Breakdown: Six Courts, Germany 2026
| Item | Range |
|---|---|
| Building lease deposit or land | €50,000€200,000 |
| Construction / conversion | €400,000€800,000 |
| 6 padel courts (installed) | €180,000€300,000 |
| Lighting (LED, 500 lux per court) | €30,000€60,000 |
| HVAC system | €50,000€120,000 |
| Changing rooms, reception, lounge | €80,000€150,000 |
| IT, booking system, access control | €15,000€30,000 |
| Furniture, equipment, pro shop inventory | €20,000€40,000 |
| Architect, permits, legal, consulting | €40,000€80,000 |
| Pre-launch marketing | €15,000€30,000 |
| Working capital reserve | €50,000€100,000 |
| **Total** | **€930,000€1,910,000** |
**Construction/conversion (€400k€800k)** is where projects go over budget most often. Before signing a lease, commission a structural assessment from a contractor experienced in sports hall conversions. A building that looks right on paper can carry hidden costs — drainage, load-bearing upgrades, fire egress — that flip a €500k construction budget to €750k.
**The courts themselves (€30k€50k each installed)** vary primarily by glass specification. Full-panorama courts with all-glass back walls cost more than standard hybrid construction. On a six-court project, the difference between the low and high end is roughly €120k — real money, but roughly 810% of total project cost. Don't let court specification decisions distort the overall project budget.
**HVAC (€50k€120k)** is consistently underestimated. A closed hall with six active courts and 60+ simultaneous players generates significant heat load and humidity. Under-speccing this system creates player complaints, structural moisture damage, and expensive remediation. Budget toward the upper end and treat it as a fixed cost of operating indoors — a well-designed system also reduces energy consumption over the full operating life.
**Working capital reserve (€50k€100k)** is not optional. In months one through six, revenue runs well below steady-state while rent and payroll are already at full run rate. This reserve is the difference between a stressful launch and a controlled one.
---
## Commercial Rent by German City
Construction and courts consume most of your initial budget. What determines long-term viability is what you pay every month: rent.
A six-court facility with changing rooms, a reception area, and a lounge requires **1,5002,500 sqm** of floor space. Current industrial/warehouse lease rates across major German cities:
| City | Rent €/sqm/month | Typical monthly cost (2,000 sqm) |
|---|---|---|
| Munich | €1014 | €20,000€28,000 |
| Berlin | €812 | €16,000€24,000 |
| Frankfurt | €811 | €16,000€22,000 |
| Düsseldorf | €811 | €16,000€22,000 |
| Hamburg | €710 | €14,000€20,000 |
| Stuttgart | €710 | €14,000€20,000 |
| Cologne | €69 | €12,000€18,000 |
| Leipzig | €47 | €8,000€14,000 |
In the tightest urban submarkets — central Berlin, Munich's inner districts — even warehouse and light-industrial space increasingly commands premium rates. Locations 1520 minutes outside the core city center offer meaningfully lower rents without sacrificing catchment, and are worth modelling explicitly.
One structural note: German commercial landlords typically require lease terms of 510 years for hall-scale premises. That creates long-term commitment, but it also gives lenders a bankable asset — a long lease with indexed rent escalation reads as revenue visibility, not risk, on a credit application.
---
## Court Hire Rates: What the Market Will Bear
Revenue potential tracks location almost as closely as rent does. The following booking rates are drawn from platform data and direct market surveys:
| City | Off-Peak (€/hr) | Peak (€/hr) | Confidence |
|---|---|---|---|
| Berlin | €33 | €46 | High |
| Munich | €30 | €42 | Estimated |
| Düsseldorf | €30 | €42 | Estimated |
| Hamburg | €26 | €36 | Medium |
| Stuttgart | €26 | €38 | Medium |
| Frankfurt | €24 | €28 | High |
| Cologne | €22 | €27 | High |
| Leipzig | €18 | €26 | Estimated |
The Playtomic Global Padel Report 2025 provides a useful market-level cross-check: Germany's average GMV per court grew **48% year-on-year to €4,000/month** at approximately 30% utilization. That implies a blended effective rate of around **€30/hour** — consistent with the figures for mid-tier German cities at 30% fill rates.
For the revenue model in this article, we use a blended rate of **€45/hour** — a weighted average of off-peak and peak pricing for a well-positioned facility in an upper-tier German city. If your location is a smaller market, stress-test your model at €28€32.
---
## Operating Costs (OPEX)
Operating cost projections are where business plans most often diverge from reality. The figures below reflect actual operating structures for six-court halls in the German market:
| Cost item | Year 1 | Year 2 | Year 3 |
|---|---|---|---|
| Rent / lease | €120,000 | €123,000 | €127,000 |
| Staff (58 FTE) | €200,000 | €220,000 | €235,000 |
| Energy (lighting, HVAC) | €45,000 | €50,000 | €55,000 |
| Maintenance & repairs | €20,000 | €25,000 | €30,000 |
| Marketing | €40,000 | €30,000 | €25,000 |
| Insurance | €12,000 | €12,000 | €13,000 |
| Booking system / IT | €8,000 | €8,000 | €9,000 |
| COGS (F&B, shop) | €25,000 | €40,000 | €48,000 |
| Admin, accounting, legal | €20,000 | €22,000 | €24,000 |
| **Total OPEX** | **€490,000** | **€530,000** | **€566,000** |
Note: the rent line reflects a well-positioned facility in a mid-tier city. For Munich or Berlin, adjust upward using the city rent table above — and recalibrate your revenue assumptions accordingly.
**Staffing** is the line that most first-time operators get wrong. Five FTEs is a genuine minimum for professional operations — reception, court management, a coach, administration. In Germany, employer social security contributions add roughly 20% on top of gross wages. €200k in Year 1 for a five-person team is lean, not generous.
**Energy** depends heavily on the building envelope. An older warehouse with poor insulation and an oversized, inefficient HVAC installation can run 3050% higher than the figures shown here. Commissioning a quick energy audit before signing the lease is cheap insurance.
**Marketing** is front-loaded by design. Pre-launch campaign, opening events, league partnerships — these drive the initial community that makes Year 2 look like Year 2 in the projections below. Once you have a full league schedule and a waiting list for peak slots, the marketing budget can drop substantially.
---
## Three-Year P&L Projection
[scenario:padel-halle-6-courts:full]
The projection below assumes a blended rate of €45/hour, six courts, 14 operating hours per day (8am10pm), 365 days per year.
| Revenue stream | Year 1 (45% util.) | Year 2 (60% util.) | Year 3 (70% util.) |
|---|---|---|---|
| Court rental | €665,000 | €887,000 | €1,035,000 |
| Coaching & academy | €60,000 | €90,000 | €120,000 |
| F&B / bar | €40,000 | €65,000 | €80,000 |
| Pro shop | €15,000 | €25,000 | €30,000 |
| Events & corporate | €20,000 | €40,000 | €60,000 |
| **Total revenue** | **€800,000** | **€1,107,000** | **€1,325,000** |
| **Total OPEX** | **€490,000** | **€530,000** | **€566,000** |
| **EBITDA** | **€310,000** | **€577,000** | **€759,000** |
Court rental dominates revenue in Year 1 (83%), which is both expected and correct for a new operation. As the facility matures, coaching programs, corporate bookings, and F&B each contribute more — these lines carry better margins than pure court hire and meaningfully improve Year 3 EBITDA.
The EBITDA margins here — 39% in Year 1, rising to 57% by Year 3 — sit at the upper end of documented European padel benchmarks. They are achievable with disciplined staffing and energy management, but they are not automatic. Underperformance on either line will compress margins quickly.
---
## Key Financial Metrics
Five numbers your lender will ask about — and that you should be able to justify with your own sensitivity analysis:
**Payback period: 35 years**
At a €1.4M total project cost (midpoint) and free cash flow of €200k+ in Year 1 scaling to €650k+ by Year 3, equity payback lands in the 35 year range depending on your debt structure. For a leisure-infrastructure investment, that is a strong return profile.
**Break-even utilization: 3540%**
Below 35% utilization, the hall typically does not cover its running costs. This sounds low in isolation — in practice, the first six months of operation routinely run at 2530%, which is why the working capital reserve exists. Model the monthly cash position through the ramp-up explicitly.
**Revenue per court target: €150k+ at maturity**
Year 3 in the model above: €1,035k court revenue ÷ 6 courts = €172,500 per court. This is the operational benchmark for a well-run facility. Tracking revenue per court is more useful than aggregate revenue for comparing performance across differently sized halls.
**Cash-on-cash ROI: 60%+ by Year 3**
With €500k equity deployed and €300k+ annual free cash flow at maturity, cash-on-cash return exceeds 60% — provided the debt service is covered. This assumes a sensible financing structure, not all-equity.
**Annual debt service: ~€102k**
On an €800k loan at 5% over 10 years, annual debt service is approximately €102k. At Year 1 EBITDA of €310k, the debt service coverage ratio (DSCR) is 3.0 — well above any lender's threshold. The stress test is: what does DSCR look like at 35% utilization? Run that number before your first bank meeting.
---
## What Lenders Actually Look For
A padel hall is an unfamiliar asset class for most bank credit officers. They have no mental model for court utilization rates or booking yield — and that is actually an opportunity. What moves a credit committee is not enthusiasm for the sport. It is the rigor of the financial documentation. Arrive with clean numbers and you stand out from the start.
**DSCR of 1.21.5x minimum.** Lenders want operating cash flow to cover debt service with a 2050% buffer. The base case in this model clears that bar easily; your job is to show it holds under stress scenarios too.
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal — it converts uncertain future revenue into something closer to contracted income on the credit committee's worksheet.
**Monthly cash flow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
**Sensitivity analysis.** Show three scenarios: base case (4560% utilization), downside (35%), and stress (25%). If your project only works at optimistic assumptions, that is important information — for you, not just for the bank.
A dedicated article on structuring a padel hall business plan and navigating German bank and KfW financing options covers this in full detail.
---
## Bottom Line
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.21.5M as the honest planning figure for a solid six-court operation. The economics, done right, are genuinely attractive — payback in 35 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,187 @@
---
title: "How to Finance a Padel Hall in Germany: Loans, Grants, and KfW Programs in 2026"
slug: padel-hall-financing-germany
language: en
url_path: /padel-hall-financing-germany
meta_description: "KfW Unternehmerkredit, ERP-Kapital, state development banks: a practical guide to financing a padel hall in Germany in 2026 — with specific programs, amounts, and structures."
cornerstone: C6
---
# How to Finance a Padel Hall in Germany: Loans, Grants, and KfW Programs in 2026
The financing question stops more padel hall projects than any technical challenge. Not because the economics don't work — well-run padel halls generate strong returns — but because the capital requirements are substantial, the subsidy landscape is complex, and German banks ask hard questions about single-asset leisure investments.
This guide lays out the full financing architecture for a padel hall in Germany: which KfW programs apply, what the state development banks offer, and how to structure equity, debt, and subsidies into a package that gets a yes from your bank while keeping your personal exposure manageable.
---
## The Basic Structure: Equity, Bank Debt, and Subsidies
A padel hall is capital-intensive. With a realistic total investment of **€1.21.5M** for a 6-court indoor facility, the financing typically looks like this:
| Source | Share | Amount (€1.3M example) |
|---|---|---|
| Founder equity | 2030% | €260,000€390,000 |
| KfW / development bank | 2030% | €260,000€390,000 |
| Commercial bank loan | 4060% | €520,000€780,000 |
This three-way split isn't arbitrary: German banks typically require at least 2030% genuine equity. KfW funding supplements — not replaces — commercial debt. The state development bank layer is the piece most international investors miss entirely, and it can meaningfully improve your financing terms.
**Critical process point:** KfW loans always flow through your Hausbank (the commercial bank you apply to). You do not apply to KfW directly. Your bank submits the application, retains the credit decision, and holds all or part of the default risk. This means your Hausbank relationship matters from day one.
---
## KfW Programs Relevant for Padel Hall Developers
### KfW Unternehmerkredit (Program 037/047) — The Workhorse
The most broadly applicable KfW program for established businesses.
**Eligibility:** Businesses operating for at least 2 years, plus freelancers and sole traders.
**What it finances:** Capital investments (equipment, fit-out, property), land acquisition (up to 50% of investment costs), and working capital.
**Terms:**
- Loan amount: up to **€25M** (typical for padel halls: €300k€1M)
- Tenor: up to 20 years for investments, 5 years for working capital
- Grace period options: possible — important for the pre-revenue ramp-up
- Rate: fixed or variable, at current market levels with KfW's below-market refinancing benefit passed through
**Why it matters:** Unternehmerkredit is the most flexible KfW product. If you're building a padel hall through an existing GmbH, or adding courts to an existing sports complex, this is typically the first program your bank will suggest.
---
### ERP-Kapital für Gründung (Program 058) — The Strategic Underutilized Tool
Often overlooked, frequently the highest-leverage instrument for new padel hall projects.
**Eligibility:** Founders and businesses in the first 5 years after formation, investing in a primary business.
**What it is:** Not a conventional bank loan — this is **subordinated capital** that sits in your balance sheet like equity. It directly improves your equity ratio for all subsequent financing.
**Terms:**
- Amount: up to **€500,000** per project
- Tenor: **15 years, with 7 years interest-only** — exceptionally favorable for the ramp-up period
- No collateral required for the ERP portion (the bank's liability is capped)
- Rate: below market, as this is federal development funding
**The leverage mechanism:** If you're starting with €250k of personal equity, ERP-Kapital adds another €250k of equity-equivalent capital — and your bank now sees €500k of equity-like funding rather than €250k. That difference can be the decisive factor in whether a commercial bank approves the loan. **This is the mechanism most padel hall founders don't know about.**
---
### KfW-Gründerkredit StartGeld (Program 067) — Smaller Supplement
**For:** Founders in the first 5 years, small businesses.
**Terms:** Up to €125k, with KfW absorbing 80% of default risk (your bank only carries 20%). Simplified review, faster processing.
**Limitation:** Too small to anchor a full padel hall build (€1.2M+), but useful as a top-up for specific sub-investments — IT infrastructure, the booking system build-out, pro shop stock.
---
## German State Development Banks: Often More Favorable than KfW
Each of Germany's 16 states (*Bundesländer*) has its own development bank with programs that complement or sometimes beat KfW on specific terms. Check these **in addition to** KfW — they are not alternatives, they stack.
### NRW.BANK (North Rhine-Westphalia)
**NRW.BANK Gründungskredit:** For new businesses in NRW. Competitive rates, grace period options. Well-suited for first-time padel operators in Germany's most populous state.
**NRW.BANK Mittelstandskredit:** For established NRW businesses investing in growth. Partial guarantee options available.
Contact: nrwbank.de
---
### Investitionsbank Berlin (IBB)
**IBB Investitionskredit / IBB Gründungskredit:** Particularly relevant given Berlin's strong padel demand — consistently one of the highest-occupancy markets in Germany. The IBB is accessible and responsive for sports-adjacent investments.
Contact: ibb.de
---
### LfA Förderbank Bayern
**LfA StartCredit:** For young Bavarian companies (first 3 years). Can be combined with ERP-Kapital.
**LfA Wachstumskredit:** For established Bavarian businesses.
Contact: lfa.de
---
### Other State Banks
Every state has a development bank: Investitionsbank Schleswig-Holstein, Thüringer Aufbaubank, NBank Niedersachsen, Sächsische Aufbaubank, and others. Programs vary but are consistently worth checking.
**Practical resource:** förderinfo.de (operated by Germany's Federal Ministry for Economic Affairs) lists all relevant federal and state programs based on your location and business type.
---
## Personal Guarantee Reality: Don't Avoid This Conversation
Once the debt structure is in place, there is one more item that belongs in every financing conversation — and that is too often skipped until the term sheet arrives.
German banks financing a padel hall through a standalone project company will almost always require **persönliche Bürgschaft** (personal guarantee) from the founders. This means your personal assets — home, savings, existing investments — are at risk if the business fails.
Three ways to limit this exposure:
1. **Bürgschaftsbanken (Credit Guarantee Associations):** Every state has one. They can take over up to 80% of a guarantee, dramatically reducing your personal exposure. Applications run parallel to your bank application.
2. **KfW guarantee exemption:** Some KfW programs include partial bank liability exemption — reducing how much guarantee the commercial bank requires.
3. **Silent partner / co-investor:** A silent investor who contributes equity reduces the bank loan size, and therefore the guarantee requirement.
What every business plan must address: an explicit section on the guarantee structure. Bankers notice when founders pretend this isn't a real risk. Addressing it directly signals maturity.
---
## Financing a Padel Hall: A Worked Example
A founder is building a 6-court indoor hall in Cologne. Total investment: **€1.3M**.
| Building block | Instrument | Amount |
|---|---|---|
| Personal equity | Founder capital | €230,000 |
| Subordinated capital | ERP-Kapital für Gründung | €250,000 |
| State development loan | NRW.BANK Gründungskredit | €180,000 |
| Commercial bank loan | Hausbank investment credit (KfW-refinanced) | €640,000 |
| **Total** | | **€1,300,000** |
From the bank's perspective: €480k of equity-equivalent funding (personal + ERP) against €820k of debt instruments. Equity ratio on total funding: 37%. That's comfortable territory.
DSCR on the bank loan (€640k at 5% over 10 years → ~€82k/year): With projected Year 2 EBITDA of €520k (conservative for Cologne's market), coverage is 6.3x. Even in the downside scenario at 20% lower utilization (EBITDA ~€350k), coverage is 4.3x — well above the 1.21.5x covenant threshold.
---
## Ten Practical Steps to Getting Financed
1. **Complete your business plan and financial model** — no credible numbers, no bank meeting
2. **Approach your Hausbank first** — the relationship bank that holds your existing accounts
3. **Apply for KfW through your Hausbank** — the bank advises on which programs fit your profile
4. **Check the state development bank in parallel** — most operators use KfW + Landesbank combinations
5. **Contact the Bürgschaftsbank** — if equity is tight or you want to reduce personal guarantee exposure
6. **Engage a tax advisor early** — legal entity choice (GmbH vs. GmbH & Co. KG), VAT recovery on construction, depreciation structure
7. **Approach multiple banks in parallel** — never exclusive; compare terms across at least 3 institutions
8. **Document equity sources** — banks need proof of where your equity comes from (bank statements, property valuations)
9. **Apply for subsidies before breaking ground** — KfW and state programs require application *before* construction begins
10. **Get a bank commitment letter before signing** — secure financing confirmation before signing the lease or land purchase contract
---
## Summary
Financing a padel hall in Germany is solvable — but only with the right preparation. The combination of founder equity, ERP subordinated capital, state development bank debt, and commercial bank debt is the realistic path for most projects between €1.0M and €1.5M. ERP-Kapital für Gründung is the most underutilized lever, with the highest balance-sheet impact per euro.
Your most powerful tool in every bank meeting: a complete financial model demonstrating the specific economics of your location, pricing, and financing structure.
[scenario:padel-halle-6-courts:full]
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,229 @@
---
title: "The 14 Risks of Opening a Padel Hall That Most Investors Underestimate"
slug: padel-hall-investment-risks
language: en
url_path: /padel-hall-investment-risks
meta_description: "Trend risk, competitor cannibalization, personal guarantees, construction overruns: honest risk assessment for padel hall investors, from the data."
cornerstone: C7
---
# The 14 Risks of Opening a Padel Hall That Most Investors Underestimate
Most padel hall business plans look good. Utilization assumptions of 6570%, five or six courts, a revenue line for corporate clients — run the numbers and the returns look compelling.
The problem is rarely the math. The problem is what's missing from it.
This article covers the 14 risks that don't get enough airtime in investor discussions. Not because padel halls are bad investments — the economics, done right, are genuinely attractive. But the ones that fail almost always failed because someone skipped this conversation. An honest look at the downside protects your capital and produces better decisions.
---
## The 14 Risks at a Glance
| # | Risk | Category | Severity |
|---|------|----------|----------|
| 1 | Trend / fad risk | Strategic | <span class="severity severity--high">High</span> |
| 2 | Construction cost overruns | Construction & Development | <span class="severity severity--high">High</span> |
| 3 | Construction delays | Construction & Development | <span class="severity severity--high">High</span> |
| 4 | Landlord risk: sale, insolvency, non-renewal | Property & Lease | <span class="severity severity--high">High</span> |
| 5 | New competitor in your catchment | Competition | <span class="severity severity--medium-high">MediumHigh</span> |
| 6 | Key-person dependency | Operations | <span class="severity severity--medium">Medium</span> |
| 7 | Staff retention and wage pressure | Operations | <span class="severity severity--medium">Medium</span> |
| 8 | Court surface and maintenance cycles | Operations | <span class="severity severity--medium">Medium</span> |
| 9 | Energy price volatility | Financial | <span class="severity severity--medium">Medium</span> |
| 10 | Interest rate risk | Financial | <span class="severity severity--medium">Medium</span> |
| 11 | Personal guarantee exposure | Financial | <span class="severity severity--high">High</span> |
| 12 | Customer concentration | Financial | <span class="severity severity--medium">Medium</span> |
| 13 | Noise complaints and regulatory restrictions | Regulatory & Legal | <span class="severity severity--medium">Medium</span> |
| 14 | Booking platform dependency | Regulatory & Legal | <span class="severity severity--low-medium">LowMedium</span> |
---
## 1. Trend Risk: Is Padel Still Here in 2035?
This is the risk nobody wants to say out loud — which makes it the one worth examining most carefully.
Padel is genuinely booming. Player numbers in Germany have grown consistently for six consecutive years. Courts are full, waitlists are real, media coverage is accelerating. All of that is true right now.
But you're not building for right now. A padel hall is a 1015 year investment thesis. The question is whether padel reaches self-sustaining critical mass in your specific market — or whether it peaks, plateaus, and slowly deflates as the novelty wears off.
Squash followed a strikingly similar pattern in the 1980s: grassroots boom, infrastructure build-out, then a long, slow decline. Anyone who opened a squash center in 1988 lived through the consequences.
The counterargument has real merit: padel requires permanent, fixed courts. That infrastructure creates genuine stickiness that squash never had — players build habits, drive to a venue, become regulars. Padel is also demonstrably more accessible and social than squash, which supports long-term participation. German player numbers show no plateau effect yet.
Even so — if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
---
## 2 & 3. Construction and Development Risk: Overruns Are the Rule
Sports facility builds almost never come in at the original budget. Cost overruns of 1530% versus the first estimate are industry-standard, not exceptional. This is partly contractor behavior, partly scope creep, partly the genuine complexity of converting commercial or industrial space for athletic use.
Construction delays compound the financial hit. Every month your hall isn't open is a month you're paying rent, debt service, and potentially contracted staff with zero revenue coming in. For a mid-size facility with €30,00050,000 in monthly fixed costs, a four-month delay adds €120,000200,000 to your actual project cost.
**What prudent planning looks like:**
- Build a minimum 1520% contingency buffer into the budget — not aspirationally, but as a hard floor
- Pursue fixed-price contracts wherever possible; read the risk allocation provisions, not just the headline price
- Model a specific delay scenario (three to six months) in your financial plan and verify the business can survive it
---
## 4. Property and Lease Risk: The Building Belongs to Someone Else
Lease-based operators often invest €500,000 or more fitting out a building they don't own. That's not inherently problematic — but it creates a set of risks that need active management.
What happens if the landlord sells to a buyer with different plans? What if the landlord goes insolvent and the administrator terminates your lease? What if after ten years no renewal is offered — and while your fit-out costs are fully depreciated, your business would effectively need to restart from scratch?
**The minimum terms worth fighting for in a lease negotiation:**
- Minimum 15-year initial term, ideally longer
- Renewal options with pre-agreed rent escalation formulas
- Compensation clauses covering tenant improvements in the event of early termination by the landlord
- Right of first refusal or consent rights on ownership transfer, if negotiable
Get a commercial property lawyer involved before signing. The few thousand euros in legal fees are among the highest-ROI expenditures in the entire project.
---
## 5. Competitive Risk: Your Success Is an Invitation
Full courts and waitlists are a great problem to have. They're also a signal to other investors: there's money to be made here.
When a new competitor opens ten minutes away in year three, you feel it in utilization. A drop from 70% to 50% sounds manageable until you model it against your fixed cost base. Depending on your leverage and lease obligations, that delta can mean the difference between a profitable operation and one that needs emergency cash.
Padel has no real moat. No patents, no network effects, no meaningful switching costs. What you have is location, the community you've built, and service quality — genuine advantages, but ones that require continuous investment to maintain.
**Model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Thinking through the competitive response in advance means you won't be improvising when it happens.
---
## 68. Operational Risks: Three Factors That Get Overlooked
### Key-Person Dependency
Many padel halls launch with one person holding everything together — a founder who handles operations, sales, and programming, or a head coach who brings their network with them. What happens when that person leaves or burns out?
The answer is process documentation, distributed responsibility, and compensation structures that don't create unhealthy dependence on any individual. Build this from day one, not year three.
### Staff Retention and Wage Pressure
Good facility managers, coaches who combine technical skill with genuine hospitality, and reliable front-desk staff are not easy to find or keep. The German labor market is tight. Shift work in a physically demanding environment creates natural churn. Model realistic staff turnover and the associated recruiting and training costs — they're a real operating expense, not a rounding error.
### Court Surface and Maintenance Cycles
Courts need replacing. Artificial turf has a lifespan of five to eight years. Glass panels and framework require regular inspection and periodic replacement. If this isn't in your long-term financial model, you're looking at a significant unplanned capital call in year six or seven. Budget a per-court annual refurbishment reserve — and set it conservatively above zero.
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before committing to running it in-house.
---
## 912. Financial Risks: The Four Silent Killers
### Energy Price Volatility
Indoor halls consume meaningful energy: court lighting, climate control, ventilation, hot water. The energy price spikes of 20212022 were a stress test many leisure facilities failed. Fixed-price energy contracts, LED lighting at proper lux standards, and efficient HVAC systems aren't just cost-saving measures — they're risk management instruments.
### Interest Rate Risk
Financing a padel hall typically involves a six-to-twelve-month gap between planning and loan drawdown. Interest rates can shift materially in that window. On a €900,000 debt facility, a 200-basis-point increase adds roughly €18,000 in annual interest expense — every year for the life of the loan. Lock your rate early where possible; if you can't, stress-test your model at current rates plus two percentage points.
### Customer Concentration
If three or four corporate clients account for 30% of your revenue — employee wellness programs, team events, regular bookings — that's attractive until one of them restructures or cuts the discretionary budget. Diversifying your revenue base across individual members, drop-in players, leagues, and corporate accounts isn't just good strategy; it's risk management.
### Inflation Pass-Through
Your costs will increase three to five percent per year. Whether you can pass those increases to customers without losing utilization depends entirely on your competitive position. In a market with multiple operators, pricing power is limited. This question deserves explicit analysis in your business plan, not an assumption that it'll work itself out.
---
## The Risk No One Talks About: Personal Guarantees
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">This section gets skipped in almost every padel hall investment conversation. That's a serious mistake.</span>
<p>Banks financing a single-asset leisure facility without corporate backing will almost universally require personal guarantees from the principal shareholders. Not as an unusual request — as standard terms for this type of deal.</p>
</div>
</div>
Here is what that means in practice:
You form a GmbH (or equivalent limited liability entity). The GmbH takes the loan. The bank, knowing the GmbH has no operating history and a single illiquid asset, requires you to sign a personal guarantee — unlimited, or up to a defined amount, typically the full loan value.
If the GmbH becomes insolvent, the bank doesn't stop at the company. It comes after you personally. Your home. Your savings. Your investment portfolio. The "limited liability" of the GmbH is functionally meaningless in this scenario for the guaranteeing shareholders.
The numbers make this concrete: a personal guarantee on an €800,000 loan means your entire private net worth is backstopping a single venue. If the hall closes in year two — due to a delayed opening, a failed market, a major competitor, or any combination of factors — you're not just losing the investment. You're potentially losing everything.
**What to actually do about this:**
1. **Before signing:** Have a lawyer review the guarantee terms. A cap on personal exposure is often negotiable; unlimited guarantees are not inevitable.
2. **Private asset planning:** Work with a financial adviser before the project begins on what private assets can be structured appropriately. Do this early — not after the bank has already submitted terms.
3. **The honest stress test:** Can you personally absorb the worst-case outcome? Not just financially, but in practical terms — what does your life look like if this fails? If you can't honestly answer this, you're not ready to proceed.
4. **Multi-shareholder structures:** If you're investing with partners, clarify upfront who guarantees, for how much, and on what terms. Undiscussed assumptions here become serious conflicts later.
No other risk in this article is as immediate and personal as this one. Approach it with that level of seriousness.
---
## 1314. Regulatory and Legal Risks
### Noise Complaints
Padel generates distinctive noise — ball impacts on glass walls carry further and at different frequencies than most sport sounds. Near residential areas, this creates genuine conflict potential. Municipalities can impose operating hour restrictions or mandate expensive acoustic retrofits.
Before signing any lease: commission a professional noise assessment. Verify that the planned use is permissible under applicable noise ordinances at that specific site and with that specific building configuration. This is not due diligence you can do retrospectively.
### Booking Platform Dependency
Playtomic is the dominant booking platform in most European padel markets. That convenience comes with concentration risk. If Playtomic raises commissions, changes its algorithm to favor partner venues, or otherwise alters terms, you have limited ability to resist if you've built your customer acquisition entirely through their platform.
Building a parallel booking capability — even a simple direct booking option — is a medium-term priority. It preserves your customer relationships and maintains margin optionality.
---
## What Good Risk Management Actually Looks Like
The investors who succeed long-term in padel aren't the ones who found a risk-free opportunity. There isn't one. They're the ones who went in with their eyes open.
<div class="article-cards">
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Model the bad scenarios first</span>
<p class="article-card__body">A business plan showing only the base case isn't a planning tool — it's wishful thinking. Explicit downside modeling — 40% utilization, six-month delay, new competitor in year three — is the baseline, not an optional exercise.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Build structural buffers in</span>
<p class="article-card__body">Liquid reserves covering at least six months of fixed costs. Construction contingency treated as a budget line, not a hedge. These aren't comfort margins; they're operational requirements.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Get the contractual foundations right</span>
<p class="article-card__body">Lease terms. Financing conditions. Guarantee scope. The cost of good legal and financial advice at the planning stage is trivial relative to the downside exposure it addresses.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Plan for competition</span>
<p class="article-card__body">Not by hoping it won't come, but by building a product — community, quality, service — that gives existing customers a reason to stay when someone cheaper opens nearby.</p>
</div>
</div>
</div>
---
## Model the Downside with Padelnomics
The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
Good decisions need an honest model — not just the best-case assumptions.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,193 @@
---
title: "Where to Build a Padel Hall: A Data-Driven Location Analysis"
slug: padel-hall-location-guide
language: en
url_path: /en/blog/padel-hall-location-guide
meta_description: "8 criteria for choosing the right location for a padel hall: catchment area, competition, visibility, rent costs, and building regulations — data over gut feeling."
cornerstone: C5
---
# Where to Build a Padel Hall: A Data-Driven Location Analysis
The location decision is the only decision in a padel hall's lifecycle that can't be undone. Poor pricing can be adjusted. A weak marketing strategy can be overhauled. A court surface that turns out to be the wrong choice can be replaced in a few years. The location cannot. Committing to a site based on instinct, or because the rent looked good, embeds a structural risk into every projection that follows. This guide walks through how a data-driven location decision actually works.
---
## The 8 Criteria for Padel Hall Site Selection
### 1. Catchment Area Analysis
Before any property is seriously evaluated, the catchment area must be understood. Start with two drive-time isochrones from the candidate site: 15 minutes and 30 minutes. Within these zones, analyse the population — not by headcount alone, but by the metrics that predict padel demand:
**Age distribution**: The core padel demographic is 2555. Areas with a median age above 55 or a very young demographic without disposable income are harder markets.
**Household income**: Destatis publishes income data at district (*Kreis*) level in Germany, and equivalent regional statistics exist across DACH. Padel isn't luxury, but it isn't mass-market either. Dual-income households with a net monthly income above €3,000 represent the strongest demand cohort.
**Employment profile**: Areas with high concentrations of knowledge workers, professionals, and dual-income households show the highest willingness to pay for fixed-schedule indoor sports bookings.
**Existing sports participation**: Is there an active tennis or squash community in the area? Padel has a high conversion rate from both sports — shared audience demographics and transferable technique lower the marketing effort required.
A strong catchment area looks like this: high population density within 15 minutes, age median 3045, above-average household income, and existing sports infrastructure that proves a sports-active population is already there.
### 2. Competition Mapping
What padel facilities already exist in the catchment area, and how well-utilized are they? This is the single most important question in the site decision.
Existing padel halls list their courts on booking platforms like Playtomic and Matchi. Spend 30 minutes checking availability for the next weekend across your candidate competitors. Look specifically at peak slots — weekday evenings (17:0021:00) and weekend mornings (09:0014:00):
- Courts fully booked at peak: a clear demand signal. The market is absorbing supply. Room for well-located new entrants.
- Substantial availability at peak: either the market is already served, or the venue has an operational problem. Investigate before concluding.
A practical distance heuristic for modeling utilization impact: a competing venue within 5km will typically cost a new hall 1525% utilization; within 10km, expect 515%. These aren't fixed laws, but they provide a sensible baseline for scenario planning.
Important nuance: competition is not a disqualifier. In markets with genuine demand and insufficient supply, a second or third hall can perform strongly. The question is always the ratio of demand to supply, not the mere existence of competitors.
### 3. Accessibility and Parking
Padel is overwhelmingly a car-visited sport. This has direct operational consequences.
**Parking**: The working benchmark is 23 spaces per court minimum. A four-court facility needs at least 812 dedicated spaces — plus buffer for coaches, staff, and the overlap between adjacent booking slots (players arriving early while previous players are still finishing). Parking shortfalls become visible only at peak capacity, and by then they're structurally unfixable.
**Public transit**: Not a dealbreaker in most DACH markets, but a genuine multiplier where it exists. A facility accessible by S-Bahn or metro reaches a meaningfully broader audience, particularly younger players and urban households without a second car.
Industrial and business park locations often have good car access but no transit connections. That's operationally acceptable for many padel halls — but it should be factored into the target customer profile and marketing assumptions.
### 4. Visibility and Location Profile
A site on a main road or near a commercial hub generates passive awareness. People see the facility while going about their daily routines, without having searched for it. This reduces the marketing effort required during the launch and ramp-up period.
A site tucked into a secondary industrial estate with no street presence also works — but it demands 3040% more marketing investment to build awareness from scratch, and requires a stronger digital presence to compensate for the absence of organic traffic.
The tradeoff: premium visible locations typically command 2x the rent per square meter compared to equivalent space in secondary locations. The question to answer is always: what does the visibility premium cost annually, and what would the same budget accomplish as marketing spend? In many cases, the less visible but cheaper location with a proper marketing budget outperforms the visible premium location on net economics.
### 5. Building Suitability for Conversion
Not every large building is actually suitable for a padel hall. Specific structural requirements apply:
**Clear height**: Minimum 8 metres of unobstructed ceiling height, ideally 10 metres or more. Below 8 metres, play is possible only with modified court dimensions and is unsuitable for competitive or club-standard use.
**Column-free spans**: A standard padel court occupies 20m × 10m. With mandatory safety zones, each court requires a column-free area of approximately 22m × 12m. Halls with tight structural grids typically don't work.
**Floor capacity**: Padel court steel structures are not exceptionally heavy, but they need to be anchored properly. The substrate must have adequate load-bearing capacity.
**Power supply**: LED lighting to padel standard (300500 lux on playing surface) draws significant electricity. The existing supply connection needs to support this, or be upgradable without prohibitive cost.
German industrial and warehouse buildings from the 1980s and 1990s frequently meet these criteria and are often available at substantially lower rent per square meter than retail or office space. They represent the most common conversion path for padel halls in DACH.
### 6. Rent-to-Revenue Ratio
Padel halls need substantial floor area: 1,5003,000 sqm for a 48 court facility including changing rooms, lounge, reception, and storage. Rent per sqm therefore has an outsized impact on the unit economics.
**The working rule**: annual total rent should not exceed 15% of projected Year 3 revenue. Worked example:
- Projected Year 3 revenue: €1.1 million
- Maximum sustainable annual rent: €165,000 (15%)
- Monthly rent: €13,750
- At 1,500 sqm: approximately €9.20/sqm/month
If the asking rent sits materially above this threshold, the site has a structural economics problem — regardless of how well it scores on other criteria. This is one of the two hard disqualifiers in site selection (the other being building unsuitability).
The 15% rule is a planning ceiling, not a guarantee of viability. Facilities with strong ancillary revenue (F&B, coaching, events) can tolerate a higher rent burden; lean court-rental-only operations need to be below it.
### 7. Area Growth Trajectory
Is the surrounding area developing? New residential or commercial development nearby can significantly expand the catchment base during the first years of operation. Securing a site ahead of completed area development often means lower rent and a first-mover position that becomes increasingly valuable as the area fills in.
Information sources: municipal development plans (*Bebauungspläne*), land use plans (*Flächennutzungspläne*), reports from the local economic development office (*Wirtschaftsförderung*), and regional population projections from national statistics offices (Destatis for Germany, Statistics Austria, Swiss Federal Statistical Office). Reviewing building permit statistics for the surrounding area gives a useful leading indicator of near-term population growth.
### 8. Regulatory Environment
The building permit (*Baugenehmigung* in Germany) is one of the most consistently underestimated risk factors in padel hall projects. Processing times of six to nine months are not exceptional — and every month of delay means rent running without revenue.
Key checks before committing to a site:
**Zoning (*Nutzungsklasse*)**: Is commercial sports use permissible at this location under the *Baunutzungsverordnung*? Commercial sports facilities are not permitted in all zone types. A pre-inquiry (*Voranfrage*) to the building authority — typically informal and often free — can answer this before any lease is signed.
**Noise regulations**: Critical for outdoor courts. Germany's *TA Lärm* sets strict noise level thresholds for sports facilities depending on the surrounding zone category. These regulations can permanently restrict outdoor court operation if the site is near residential zones. Assess this before investing in outdoor infrastructure.
**Municipal support**: Some municipalities actively want sports infrastructure — expedited permitting, discounted commercial space, or direct funding. Contacting the local *Wirtschaftsförderung* early in the site selection process costs nothing and occasionally surfaces meaningful support.
---
## The Site Scoring Framework: From 8 Criteria to a Decision
Any investor evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 15 and multiplied by a weighting factor.
A suggested weighting:
| Criterion | Weight |
|---|---|
| Catchment area (population, income, demographics) | 25% |
| Competitive landscape | 20% |
| Rent-to-revenue ratio | 20% |
| Building suitability | 15% |
| Accessibility and parking | 10% |
| Regulatory environment | 5% |
| Visibility | 3% |
| Area growth trajectory | 2% |
This produces a total score per site that enables structured comparison. Important caveat: a site that fails either of the two hard disqualifiers — rent-to-revenue ratio above the threshold, or building structurally unsuitable — is eliminated regardless of total score.
The matrix also reveals where trade-offs are being made explicitly, which makes conversations with co-investors, partners, and banks more grounded.
---
## Common Mistakes in Site Selection
**The visibility trap**: A premium-visibility site on a main arterial road sounds compelling. But padel customers book online. Visibility helps with passive brand awareness — it doesn't replace functional digital marketing, and it costs substantially more per sqm. Quantify what the visibility premium costs per year, and compare that to what the same budget would do as targeted digital advertising. The math often favors the less visible but cheaper site with a real marketing budget.
**Underestimating parking**: Parking problems only become fully visible at operating capacity — the busiest weekend morning when every slot is full and customers can't find a space. By that point, the problem is structural and unfixable without significant additional cost or renegotiation. Assess parking capacity before signing.
**Ignoring regulatory risk**: Planning permissions fail or stall for reasons that were often visible in advance — wrong zone type, outdoor court noise exposure, adjacent protected buildings. A pre-inquiry to the building authority before committing to a lease takes a week and can save months of wasted effort and meaningful sunk costs.
**Anchoring too early on a single site**: The best location decisions come from comparing at least three to five options side by side. Taking the first workable option forfeits the ability to optimize the rent, location quality, and suitability trade-off. The scoring matrix only pays off if there's something to compare.
---
## Reading Market Maturity: What Stage Is Your Target City?
The 8 criteria above evaluate specific sites. But before shortlisting sites, it is worth stepping back to read the stage of the overall market — because the right operational strategy differs fundamentally depending on where a city sits in its padel development cycle.
<div class="article-cards">
<div class="article-card article-card--established">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Established markets</span>
<p class="article-card__body">Booking platforms show consistent peak-hour sell-out. Demand is validated. The challenge: elevated rent, high build costs, entrenched operators. New entrants need a genuine differentiation angle — superior spec, better location, or F&B and coaching that existing venues don't offer. Entry costs are high; returns, if execution is strong, are also high. Munich is the canonical German example.</p>
</div>
</div>
<div class="article-card article-card--growth">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Growth markets</span>
<p class="article-card__body">Demand is clearly building — booking availability tightens at weekends, new facilities are announced regularly. Supply hasn't caught up; identifiable gaps still exist. The risk profile is lower, but the window for securing good real estate at reasonable rent is narrowing. The premium goes to those who arrive before the obvious sites are taken.</p>
</div>
</div>
<div class="article-card article-card--emerging">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Emerging markets</span>
<p class="article-card__body">Limited supply, a small but growing player base, padel not yet mainstream. Entry costs — rent especially — are lower. The constraint: demand must be actively created rather than captured. Operators who succeed invest in community: beginner programmes, local leagues, school partnerships. Time to profitability is longer, but the competitive position built in the first two years is often decisive.</p>
</div>
</div>
</div>
Before committing to a site search in any city, calibrate where it sits on this spectrum. The 8-criteria framework then tells you whether a specific site works; market maturity tells you what kind of operator and strategy is required to make it work at all.
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
[→ View market data by country](/en/markets/germany)
---
## How Padelnomics Helps
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
[→ Run a location analysis](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -0,0 +1,335 @@
---
title: "Padel Halle Bauen: Die 5 Phasen vom Konzept bis zur Eröffnung"
slug: padel-halle-bauen
language: de
url_path: /padel-halle-bauen
meta_description: "Wie baut man eine Padelhalle? Machbarkeit, Planung, Bau, Voreröffnung, Betrieb alle 23 Schritte in einem vollständigen Leitfaden."
cornerstone: C8
---
# Padel Halle Bauen: Die 5 Phasen vom Konzept bis zur Eröffnung
Von der ersten Idee bis zum Tag der Eröffnung vergehen realistisch 12 bis 18 Monate. Wer mit 9 Monaten plant, ist fast immer zu optimistisch — wer mit 24 rechnet, kann entspannter planen und besser verhandeln.
Dieser Leitfaden zeigt Ihnen alle 5 Phasen und 23 Schritte, die zwischen Ihrer ersten Standortrecherche und einem laufenden Betrieb liegen. Kein Hochglanzbild des Erfolgs, sondern ein ehrlicher Überblick über das, was tatsächlich passiert — und was schiefgehen kann.
---
## Die 5 Phasen im Überblick
<div class="article-timeline">
<div class="article-timeline__phase">
<div class="article-timeline__num">1</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Machbarkeit &amp; Konzept</div>
<div class="article-timeline__subtitle">Marktanalyse, Konzept, Standortsuche</div>
<div class="article-timeline__meta">Monat 13 · Schritte 15</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">2</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Planung &amp; Design</div>
<div class="article-timeline__subtitle">Architekt, Genehmigungen, Finanzierung</div>
<div class="article-timeline__meta">Monat 36 · Schritte 611</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">3</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Bau / Umbau</div>
<div class="article-timeline__subtitle">Rohbau, Courts, IT-Systeme</div>
<div class="article-timeline__meta">Monat 612 · Schritte 1216</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">4</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Voreröffnung</div>
<div class="article-timeline__subtitle">Personal, Marketing, Soft Launch</div>
<div class="article-timeline__meta">Monat 1013 · Schritte 1720</div>
</div>
</div>
<div class="article-timeline__phase">
<div class="article-timeline__num">5</div>
<div class="article-timeline__card">
<div class="article-timeline__title">Betrieb &amp; Optimierung</div>
<div class="article-timeline__subtitle">Einnahmen, Community, Optimierung</div>
<div class="article-timeline__meta">laufend · Schritte 2123</div>
</div>
</div>
</div>
---
## Phase 1: Machbarkeit & Konzept (Monat 13)
Diese Phase ist die wichtigste — und die, in der am häufigsten zu früh abgebrochen oder zu schnell vorangeschritten wird. Wer hier gründlich arbeitet, spart sich später teure Korrekturen.
### Schritt 1: Marktanalyse
Bevor Sie ein Grundstück besichtigen oder ein Finanzmodell öffnen, müssen Sie verstehen, ob der Markt für Ihre geplante Anlage trägt.
Das bedeutet konkret:
- **Spielerbefragungen:** Wie viele aktive Padelspieler gibt es im Radius von 15 bis 20 Fahrminuten? Wie stark ist die Warteliste bei bestehenden Anlagen?
- **Wettbewerb kartieren:** Welche Anlagen gibt es bereits? Welche sind in Planung? (Planungsanträge sind öffentlich einsehbar.) Was kosten deren Buchungen? Wie stark sind sie ausgelastet?
- **Demografie prüfen:** Wo wohnen die Zielgruppen — zahlungsbereite Berufstätige zwischen 25 und 55 Jahren, Unternehmen mit Wellness-Budgets, Sportvereine mit Trainingsbedarf? Liegen diese Gruppen im Einzugsgebiet des geplanten Standorts?
Eine professionelle Marktanalyse ist keine Garantie für Erfolg, aber sie schützt vor einem häufigen Fehler: der Anlage, die am falschen Ort oder für den falschen Markt gebaut wird.
### Schritt 2: Konzeptentwicklung
Aus der Marktanalyse leitet sich das Konzept ab: Wie viele Courts? Welche Zielgruppe — ambitionierte Freizeitspieler, Vereinssport, Corporate-Kunden, breite Bevölkerung? Welches Serviceniveau — einfache Buchungsanlage oder Hallenkonzept mit Lounge, Bar, Pro Shop und Coaching?
Jede dieser Entscheidungen beeinflusst Investitionsbedarf, Betriebskosten und Einnahmepotenzial. Das Konzept ist das Fundament aller weiteren Planung.
### Schritt 3: Standortsuche
Suchen Sie parallel drei bis fünf Kandidatenstandorte. Bewerten Sie diese nach:
| Kriterium | Was Sie prüfen |
|-----------|---------------|
| Erreichbarkeit | Öffentlicher Nahverkehr, Parkplätze, Radwege |
| Sichtbarkeit | Laufkundschaft, Straßenpräsenz, Beschilderungsmöglichkeiten |
| Raumgröße | Nettofläche für Courts plus Nebenräume (Umkleiden, Empfang, Lounge) |
| Deckenhöhe | Mindestens 8 Meter lichte Höhe für Indoor-Courts — 10+ für Turnierbetrieb |
| Bebauungsplan | Ist die Nutzung als Sportstätte zulässig? Lärmschutzauflagen? |
| Mietkosten | Monatsmiete im Verhältnis zum erwartbaren Umsatz |
Ein Standort, der in allen Dimensionen 70 Prozent erfüllt, ist besser als einer, der in einer Dimension herausragt, dafür in zwei anderen scheitert.
### Schritt 4: Vorläufiges Finanzmodell
An diesem frühen Punkt brauchen Sie kein Detailmodell. Was Sie brauchen: einen ersten Plausibilitätscheck.
Grobe Hochrechnung:
- Investitionsvolumen (Bau, Courts, Einrichtung, Puffer) vs. verfügbares Kapital plus Fremdfinanzierbarkeit
- Breakeven-Auslastung: Bei welchem Nutzungsgrad decken die Einnahmen die Fixkosten?
- Ist das Geschäftsmodell bei konservativen Annahmen (50 % Auslastung, nicht 70 %) noch lebensfähig?
Wenn das vorläufige Modell nur bei sehr günstigen Annahmen funktioniert, ist das ein Warnsignal — keine Einladung, an den Annahmen zu schrauben.
### Schritt 5: Go/No-go-Entscheidung
Phase 1 endet mit einer echten Entscheidung. Nicht mit "wir machen weiter und sehen mal" — sondern mit einer begründeten Antwort auf die Frage: Rechtfertigen Markt, Standort und vorläufiges Finanzmodell die deutlich höheren Kosten der nächsten Phase?
Wenn ja: weiter. Wenn nein oder wenn wesentliche Fragen offen sind: mehr Analyse oder konsequentes Stopp.
---
## Phase 2: Planung & Design (Monat 36)
In dieser Phase konkretisiert sich das Projekt. Die Kosten steigen spürbar — externe Dienstleister, Gutachter, Anwälte. Der Punkt of no return nähert sich.
### Schritt 6: Standort sichern
Unterzeichnen Sie einen Letter of Intent oder eine Optionsvereinbarung für den bevorzugten Standort. Das sichert Ihnen eine Exklusivverhandlungsphase, ohne Sie bereits vertraglich vollständig zu binden.
Lassen Sie den Mietvertrag erst unterzeichnen, wenn die Grundzüge der Planung stehen — damit Sie wissen, was Sie anmieten und ob der Standort das Konzept trägt. Gute Konditionen verhandeln Sie jetzt, nicht nach der Unterschrift.
### Schritt 7: Architekt und Fachplaner beauftragen
Beauftragen Sie einen Architekten mit nachweisbarer Erfahrung in Sportanlagen oder Hallenumbauten — nicht irgendein gutes Architekturbüro, sondern eines, das versteht, was ein Padel-Court baulich erfordert.
Was in dieser Phase entsteht:
- Grundrisse und Flächenplanung: Court-Layout, Wegeführung, Umkleiden, Empfang, Technikräume
- Statische Beurteilung: Ist die Tragkonstruktion für Courts und ggf. Tribünen geeignet?
- MEP-Planung (Haustechnik): Heizung, Lüftung, Klimaanlage, Elektro, Sanitär — das sind bei Sporthallen oft die kostenintensivsten Gewerke
- Brandschutzkonzept
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">Häufiger Fehler in dieser Phase</span>
<p>Die Haustechnik wird unterschätzt. Eine große Innenhalle braucht präzise Temperatur- und Feuchtigkeitskontrolle — für die Spielqualität, für die Langlebigkeit des Belags und für das Wohlbefinden der Spieler. Eine schlechte HVAC-Anlage ist eine Dauerbaustelle.</p>
</div>
</div>
### Schritt 8: Courtlieferant auswählen
Holen Sie Angebote von mindestens drei Court-Herstellern ein. Marktgängige Anbieter im deutschsprachigen Raum sind u.a. Mondo, Padelcreations, MejorSet und weitere europäische Hersteller.
Koordinieren Sie die technischen Anforderungen des Herstellers frühzeitig mit dem Architekten: Court-Maße, Entwässerung, Lichtanforderungen (LUX-Werte für unterschiedliche Spielniveaus), Glasspezifikationen, Fundamentaufbau. Wer das erst in der Bauphase koordiniert, zahlt für Nacharbeiten.
### Schritt 9: Detailliertes Finanzmodell
Jetzt liegt genug Material vor, um das Finanzmodell auf echte Zahlen zu stellen: reale Mietkosten, Architektenangebote, erste Bauschätzungen, Court-Preise. Verfeinern Sie alle Annahmen und führen Sie explizite Sensitivitätsanalysen durch — mindestens bei Auslastung (+/- 15 Prozentpunkte) und Baukosten (+20 Prozent).
### Schritt 10: Finanzierung sichern
Mit dem detaillierten Businessplan gehen Sie zu Banken und ggf. Fördermittelgebern. Typische Kapitalstruktur:
- 5070 Prozent Fremdkapital (Bankdarlehen)
- 3050 Prozent Eigenkapital (eigene Mittel, stille Beteiligungen, Gesellschafterdarlehen)
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönlicher Track Record, und — fast immer — eine persönliche Bürgschaft. Der separate Artikel zu Investitionsrisiken behandelt das Thema Bürgschaftsexposition ausführlich.
Klären Sie Förderprogramme: KfW-Mittel, Landesförderbanken und kommunale Sportförderprogramme können den Eigenkapitalbedarf oder die Zinsbelastung reduzieren. Diese Recherche lohnt sich.
### Schritt 11: Baugenehmigung und Behördenprozesse
In der Regel erforderlich: Baugenehmigung (Nutzungsänderung, wenn das Gebäude nicht als Sportstätte ausgewiesen ist), Lärmschutzgutachten, ggf. Umweltprüfungen.
Planen Sie für diesen Schritt ausreichend Zeit ein — je nach Gemeinde und Komplexität können Genehmigungsprozesse drei bis sechs Monate dauern. Sprechen Sie frühzeitig informell mit der zuständigen Behörde, um Überraschungen zu vermeiden.
---
## Phase 3: Bau und Umbau (Monat 612)
Der teuerste und zeitlich aufwendigste Teil des Projekts. Hier entscheidet sich, ob der Zeitplan und das Budget halten.
### Schritt 12: Bauausschreibung und Vertragsschluss
Lassen Sie Leistungsverzeichnisse durch Ihren Architekten erstellen und schreiben Sie die Gewerke aus. Entscheiden Sie, ob Sie einen Generalunternehmer beauftragen (ein Ansprechpartner, höhere Koordinationskosten im Preis) oder eine Fachgewerke-Koordination selbst übernehmen (günstiger, aber deutlich aufwendiger für Sie oder Ihren Bauleiter).
Kerntrades im Sporthallenumbau:
- **Rohbau/Statik:** Falls strukturelle Eingriffe nötig sind
- **Bodenarbeiten:** Fundamente und Drainage für die Courts
- **HVAC:** Heizung, Lüftung, Klimaanlage — bei Hallen oft 2025 Prozent der Baukosten
- **Elektro:** LED-Hallenbeleuchtung nach LUX-Norm, Unterverteilungen, Notstrom
- **Sanitär:** Umkleiden, Duschen, ggf. Bar
Verhandeln Sie Festpreise, wo möglich. Lesen Sie die Risikoverteilung in den Verträgen genauer als den Gesamtpreis.
### Schritt 13: Court-Montage
Courts werden nach Fertigstellung der Gebäudehülle montiert — das ist eine harte Reihenfolge, keine Empfehlung. Glaselemente dürfen nicht Feuchtigkeit, Staub und Baustellenverkehr ausgesetzt werden, bevor das Gebäude dicht ist.
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">Ein häufiger und vermeidbarer Fehler</span>
<p>Projekte unter Zeitdruck versuchen, die Court-Montage vorzuziehen. Das Ergebnis sind beschädigte Oberflächen, Glasschäden, Verschmutzungen im Belag und Gewährleistungsprobleme mit dem Hersteller. Halten Sie die Reihenfolge ein — konsequent.</p>
</div>
</div>
Die Montage von Courts dauert je nach Hersteller und Parallelkapazität zwei bis vier Wochen pro Charge. Planen Sie das in den Gesamtablauf ein.
### Schritt 14: Ausbau der Nebenflächen
Parallel zur oder nach der Court-Montage: Empfangstresen, Umkleiden, Lounge-Bereich, ggf. Bar/Café, Pro-Shop-Einrichtung. Diese Flächen machen den ersten Eindruck bei Besuchern und sollten nicht als nachrangig behandelt werden.
### Schritt 15: IT, Buchungssystem, Zugangskontrolle
Frühzeitig entscheiden: Playtomic, Matchi, ein anderes System oder eine Hybridlösung? Die Systemkonfiguration — Courts anlegen, Preisregeln definieren, Mitgliedschaften einrichten, Kassensystem integrieren — dauert länger als erwartet.
Zugangskontrolle (falls gewünscht) muss mit der Elektroplanung koordiniert werden. Wer das in der letzten Bauphase ergänzen möchte, zahlt dafür.
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">Der häufigste Fehler kurz vor der Eröffnung</span>
<p>Am Tag der Eröffnung ist das Buchungssystem noch nicht richtig konfiguriert, Testzahlungen schlagen fehl, der QR-Code am Eingang führt auf eine Fehlerseite. Der Eröffnungsbuzz ist ein einmaliges Gut. Testen Sie das System zwei bis vier Wochen vorher vollständig — inklusive echter Buchungen, echter Zahlungen und echter Stornierungen.</p>
</div>
</div>
### Schritt 16: Abnahmen und Zertifizierungen
Brandschutzabnahme, Bauabnahme, Betriebsgenehmigung (wo erforderlich), Barrierefreiheitsprüfung. Planen Sie für diesen Schritt vier bis acht Wochen Vorlauf gegenüber dem geplanten Eröffnungsdatum.
---
## Phase 4: Voreröffnung (Monat 1013)
Die Halle steht. Jetzt entscheidet sich, ob Sie am Eröffnungstag Kunden haben — oder ob Sie in einer leeren Halle auf den ersten Anruf warten.
### Schritt 17: Personal einstellen
Wer auf den letzten Drücker anfängt zu suchen, bekommt, wer noch verfügbar ist. Idealer Beginn der Personalsuche: drei bis vier Monate vor dem geplanten Eröffnungstermin.
Kerncrew für den Betrieb:
- **Hallenleiter:** Betriebsverantwortung, Buchungsmanagement, Kundenkontakt — die wichtigste Einstellung, und die schwierigste
- **Empfang/Service:** Für die Spitzenzeiten (Abend unter der Woche, ganzer Samstag/Sonntag)
- **Coaches:** Sofern Trainingsbetrieb geplant ist — Qualität vor Quantität
- **Reinigung:** Regelmäßige Hallenpflege ist kein nachrangiges Thema; schmutzige Courts werden bemerkt und bewertet
### Schritt 18: Marketing und Vor-Eröffnungs-Aktivierung
Warten Sie nicht bis zur Eröffnung, um bekannt zu werden. Bauen Sie die Community vor der Eröffnung auf:
- Social Media mit Baufortschritt-Updates (erzeugt Vorfreude und lokale Reichweite)
- Lokale Kooperationen: Sportvereine, Unternehmen mit Wellness-Budgets, Fitnessstudios in der Nähe
- Pressearbeit: Lokale Medien berichten gern über neue Sportinfrastruktur — aber nur, wenn Sie sie einladen
- Einführungspreise oder Gründungsmitgliedschaften: Schaffen früh einen festen Kundenstamm und stabilisieren die Frühphase-Auslastung
### Schritt 19: Soft Opening
Laden Sie vor der öffentlichen Eröffnung ausgewählte Spieler ein: Local Influencer (auch Micro-Influencer mit relevanter Zielgruppe), Sportjournalisten, Vereinsfunktionäre, Unternehmenskunden.
Ziele: echtes Feedback zu Court-Qualität und Abläufen, erste Bewertungen, Bilder und Videos von echten Spielern in einer vollen Halle. Das Soft Opening ist auch die letzte Chance, operative Probleme zu finden, bevor der Normalbetrieb beginnt.
### Schritt 20: Grand Opening
Feiern Sie die Eröffnung — aber machen Sie es nicht zum Ende der Marketingaktivität. Der Eröffnungstag ist der Anfang eines langen Aufbaus, nicht dessen Höhepunkt.
---
## Phase 5: Betrieb und Optimierung (laufend)
Der Bau ist abgeschlossen. Die eigentliche Arbeit beginnt jetzt.
### Schritt 21: Auslastung analysieren und Preise dynamisch steuern
Nicht alle Zeiten sind gleich. Montagabend 20:00 Uhr ist ausgebucht; Dienstagmittag 13:00 Uhr liegt bei 15 Prozent. Dynamische Preisgestaltung — günstigere Buchungen in Schwachzeiten, Premium in Stoßzeiten — kann die Gesamtauslastung signifikant erhöhen, ohne neue Kunden zu gewinnen.
Messen Sie: Auslastung pro Court, pro Wochentag, pro Tageszeit. Welche Courts laufen besser? Warum? Wo verlieren Sie Buchungen? Die Antworten liegen in den Daten, die Ihr Buchungssystem täglich erzeugt — nutzen Sie sie.
### Schritt 22: Community aufbauen
Eine Padelhalle mit hoher Auslastung ist keine Buchungsmaschine — sie ist ein sozialer Ort. Regelmäßige Turniere, Hobbyligen, Corporate-Events, Ladies-Nights, Anfängerkurse: Das sind die Formate, die Erstbesucher zu Stammkunden machen.
Corporates sind dabei ein unterschätztes Segment. Viele Unternehmen haben Budget für Team-Events und Mitarbeiter-Benefits, wissen aber nicht, dass Ihre Halle das anbietet. Eine direkte Ansprache mit einem klaren Angebot (Pauschalpreise für Gruppenveranstaltungen, Rahmenverträge für regelmäßige Buchungen) öffnet hier Türen.
### Schritt 23: Umsatz verbreitern
Die Court-Buchung ist Ihr Kernangebot — aber nicht die einzige Einnahmequelle:
- **Coaching:** Qualifizierte Trainer mit eigenem Kundenstamm sind ein echter Hebel, wenn die Vergütungsstruktur stimmt
- **Equipment:** Schläger-Verleih und -Verkauf, Bälle, Zubehör — geringe Investition, brauchbare Marge
- **Food & Beverage:** Wenn überhaupt, dann entweder mit echter Qualität oder ausgelagert an einen Betreiber; Halbherziges schadet dem Gesamteindruck
- **Mitgliedschaften:** Monatspakete mit garantierten Buchungskontingenten stabilisieren den Cashflow und binden Kunden mittelfristig
---
## Was erfolgreiche Bauprojekte von den anderen unterscheidet
Wer Dutzende Padelhallenprojekte in Europa beobachtet, sieht Muster auf beiden Seiten:
<div class="article-cards">
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projekte, die über Budget laufen</span>
<p class="article-card__body">Haben fast immer früh an der falschen Stelle gespart — zu wenig Haustechnikbudget, kein Baukostenpuffer, zu günstiger Generalunternehmer ohne ausreichende Vertragsabsicherung.</p>
</div>
</div>
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projekte, die terminlich entgleisen</span>
<p class="article-card__body">Haben die behördlichen Prozesse unterschätzt. Genehmigungen, Lärmschutzgutachten, Nutzungsänderungen brauchen Zeit — und diese Zeit lässt sich nicht kaufen, sobald man zu spät damit anfängt.</p>
</div>
</div>
<div class="article-card article-card--failure">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projekte, die schwach starten</span>
<p class="article-card__body">Haben das Marketing zu spät begonnen und das Buchungssystem zu spät getestet. Ein leerer Kalender am Eröffnungstag und eine kaputte Buchungsseite erzeugen Eindrücke, die sich festsetzen.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Projekte, die langfristig erfolgreich sind</span>
<p class="article-card__body">Behandeln alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt und investieren früh in Community und Stammkundschaft.</p>
</div>
</div>
</div>
Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehler, die Projekte scheitern lassen, sind fast immer dieselben. Genauso wie die Entscheidungen, die sie gelingen lassen.
---
## Die richtigen Baupartner finden
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,207 @@
---
title: "Padel Halle Finanzieren: KfW-Programme, Fördermittel und Bankdarlehen 2026"
slug: padel-halle-finanzierung
language: de
url_path: /padel-halle-finanzierung
meta_description: "KfW Unternehmerkredit, ERP-Kapital, Bundesland-Förderprogramme: Wie Sie eine Padelhalle in Deutschland 2026 finanzieren — mit konkreten Programmen und Konditionen."
cornerstone: C6
---
# Padel Halle Finanzieren: KfW-Programme, Fördermittel und Bankdarlehen 2026
Die Finanzierungsfrage ist für viele Padelhallenentwickler die härteste Nuss. Nicht weil das Vorhaben unwirtschaftlich wäre — gut geplante Paddelhallen erwirtschaften solide Renditen — sondern weil die Eigenkapitalanforderungen hoch sind, die Förderkulisse komplex ist und Banken bei sportlichen Freizeitprojekten zunächst kritisch nachfragen.
Dieser Artikel legt die Finanzierungsarchitektur für eine Padelhalle in Deutschland offen: Welche KfW-Programme kommen in Frage? Was bieten die Landesförderbanken? Wie strukturieren Sie Eigenkapital, Fremdkapital und Fördermittel so, dass die Bank Ja sagt — und Ihr persönliches Risiko in einem vernünftigen Rahmen bleibt?
---
## Die Grundstruktur: Eigenkapital, Bankdarlehen, Förderung
Eine Padelhalle ist eine kapitalintensive Investition. Bei einem Gesamtinvestitionsbedarf von realistisch **€1,21,5 Millionen** für eine 6-Court-Innenhalle setzt sich die Finanzierung typischerweise wie folgt zusammen:
| Finanzierungsquelle | Anteil | Betrag (Beispiel €1,3M Gesamt) |
|---|---|---|
| Eigenkapital (Gründer) | 2030 % | €260.000€390.000 |
| KfW / Förderbank | 2030 % | €260.000€390.000 |
| Hausbank-Kredit | 4060 % | €520.000€780.000 |
Diese Dreiteilung ist kein Zufall: Banken verlangen in der Regel mindestens 2030 % echtes Eigenkapital. KfW-Mittel können — je nach Programm — als **Ergänzung zur Hausbank** (nicht als Ersatz) hinzukommen und die Finanzierungsstruktur deutlich verbessern.
**Wichtig:** KfW-Förderkredite laufen immer über Ihre Hausbank — Sie beantragen sie nicht direkt bei der KfW, sondern gehen zu Ihrer Hausbank, die den Antrag weiterleitet. Die Bank hält das Ausfallrisiko vollständig oder anteilig.
---
## KfW-Programme für Padelhallenentwickler
### KfW Unternehmerkredit (Programm 037/047)
Das wichtigste KfW-Programm für etablierte Unternehmen und Selbstständige.
**Für wen:** Unternehmen, die mindestens 2 Jahre am Markt sind, sowie Freiberufler und Selbstständige.
**Was wird finanziert:**
- Investitionen in Anlagen, Ausstattung, Gebäude
- Grunderwerb (bis zu 50 % der Investitionskosten)
- Working Capital / Betriebsmittel
**Konditionen:**
- Kreditbetrag: bis zu **€25 Millionen** (für Padelhallenprojekte typisch: €300k€1M)
- Laufzeit: bis 20 Jahre (Investitionen) / bis 5 Jahre (Betriebsmittel)
- Tilgungsfreie Anlaufjahre möglich (wichtig für die Anlaufphase)
- Zinssatz: variabel oder fest, aktuell im Rahmen der Marktzinsen
**Warum relevant:** Der Unternehmerkredit ist das flexibelste KfW-Produkt. Für eine Padelhalle in einer bestehenden GmbH oder als Erweiterung eines Sportbetrieb-Portfolios ist dies typischerweise das erste Instrument, das Ihre Hausbank vorschlägt.
---
### ERP-Kapital für Gründung (Programm 058)
Das strategisch wertvollste Programm für Padelhallenneugründungen — oft übersehen.
**Für wen:** Gründer und junge Unternehmen (bis 5 Jahre nach Gründung), die zum ersten Mal oder erneut in ein Hauptgewerbe investieren.
**Was es ist:** Kein klassisches Bankdarlehen, sondern **nachrangiges Kapital** — es steht in der Bilanz ähnlich wie Eigenkapital. Das verbessert Ihre Eigenkapitalquote für weitere Finanzierungen erheblich.
**Konditionen:**
- Kreditbetrag: bis zu **€500.000** je Vorhaben
- Laufzeit: 15 Jahre, davon 7 tilgungsfreie Jahre — das ist außergewöhnlich günstig für die Anlaufphase
- Keine Sicherheiten für den ERP-Teil erforderlich (die Hausbank haftet begrenzt)
- Zinssatz: i.d.R. günstiger als Marktkonditionen, da Bundesförderung
**Warum strategisch:** Wenn Sie mit €250.000 Eigenkapital starten, können Sie durch das ERP-Programm €250.000 weiteres nachrangiges Kapital ergänzen — und erscheinen gegenüber der Hausbank mit €500.000 "eigenkapitalähnlichen Mitteln". Das öffnet die Tür zu einem substantiellen Bankdarlehen. **Dies ist der Hebelmechanismus, den die meisten Padelhallengründer nicht kennen.**
---
### KfW-Gründerkredit StartGeld (Programm 067)
**Für wen:** Gründer in den ersten 5 Jahren, Kleinunternehmen.
**Konditionen:**
- Bis zu **€125.000** Finanzierung
- Die Hausbank haftet nur zu 20 % — KfW übernimmt 80 % des Ausfallrisikos
- Vereinfachte Prüfung, schnellere Bearbeitung
**Einschränkung:** Für eine vollständige Padelhalle (€1,2M+) reicht StartGeld als Hauptinstrument nicht aus. Es kann aber als **Ergänzung** für spezifische Teilinvestitionen genutzt werden, beispielsweise für die IT-Infrastruktur oder den Pro-Shop-Aufbau.
---
## Bundesland-Förderbanken: Oft günstiger als KfW
Die Landesförderbanken sind häufig attraktiver als die KfW, weil sie gezielt regionale Wirtschaftsentwicklung fördern. Für Sportstätten gibt es teils spezifische Programme. Prüfen Sie diese **zusätzlich** zur KfW:
### NRW.BANK (Nordrhein-Westfalen)
**NRW.BANK Gründungskredit**: Für Unternehmensgründungen und Betriebsübernahmen in NRW. Günstige Konditionen, tilgungsfreie Anlaufjahre.
**NRW.BANK Mittelstandskredit**: Für bestehende NRW-Unternehmen, die investieren. Haftungsfreistellung möglich.
Kontakt: nrwbank.de/foerderangebote-fuer-unternehmen
---
### Investitionsbank Berlin (IBB)
**IBB Investitionskredit**: Für Berliner Unternehmen mit Investitionsvorhaben in Berlin. Besonders relevant für Padelkonzepte in Berlin, wo die Marktnachfrage besonders stark ist.
**IBB Gründungskredit**: Für Berliner Gründer in den ersten 3 Jahren.
Kontakt: ibb.de
---
### LfA Förderbank Bayern
**LfA StartCredit**: Für Neugründungen und junge Unternehmen in Bayern (bis 3 Jahre). Kombi mit ERP-Kapital möglich.
**LfA Wachstumskredit**: Für etablierte bayerische Unternehmen.
Kontakt: lfa.de
---
### Investitionsbank Schleswig-Holstein, Thüringer Aufbaubank, NBank Niedersachsen
Jedes Bundesland hat ein eigenes Institut. Die Programme variieren in Volumen und Konditionen, sind aber stets prüfenswert — insbesondere wenn Ihr Standort in einem wirtschaftsschwächeren Gebiet liegt (z.B. Fördergebiete nach Art. 107 AEUV).
**Praktischer Tipp:** Die Datenbank unter förderinfo.de (vom BMWK betrieben) listet alle relevanten Bundes- und Landesprogramme für Ihren spezifischen Standort und Ihre Unternehmensform.
---
## Sportstättenförderung: Kommunale Mittel für geeignete Projekte
Padelhallen, die als öffentlich zugängliche Sportstätten konzipiert sind, können in bestimmten Konstellationen auch kommunale Sportstättenförderung erhalten:
- **Landessportbünde** (z.B. LSB NRW, BLSV Bayern): Investitionszuschüsse für gemeinnützige Sportvereine, die Padel als Breitensportangebot anbieten. Nicht für rein kommerzielle Betreiber, aber für Vereinsanlagen hochrelevant.
- **Bundesprogramm "Sport im Revier"** (in strukturschwachen Gebieten): Investitionshilfen für Sportstättenentwicklung in bestimmten Förderregionen.
- **Kommunale Liegenschaften**: Einzelne Kommunen stellen Grundstücke zu subventionierten Konditionen zur Verfügung, wenn das Projekt dem lokalen Breitensport zugute kommt.
Diese Wege sind aufwändiger, aber der Aufwand lohnt sich: Ein Zuschuss von €100.000€200.000 verbessert Ihre Eigenkapitalquote, ohne dass Sie mehr einbringen müssen.
---
## Finanzierungsstruktur in der Praxis: Ein Beispiel
Ein Gründer baut eine 6-Court-Innenhalle in Düsseldorf. Gesamtinvestition: **€1,4 Millionen**.
| Baustein | Instrument | Betrag |
|---|---|---|
| Eigene Mittel | Eigenkapital Gründer | €250.000 |
| Nachrangkapital | ERP-Kapital für Gründung | €250.000 |
| Landesförderung | NRW.BANK Gründungskredit | €200.000 |
| Bankkredit | Hausbank-Investitionskredit (KfW-refinanziert) | €700.000 |
| **Gesamt** | | **€1.400.000** |
Die Hausbank sieht €500.000 eigenkapitalähnliche Mittel (eigenes EK + ERP) gegenüber €200.000 Landeskredit und €700.000 Bankdarlehen. Eigenkapitalquote auf Basis der gesamten Passivseite: 36 %. Das ist eine komfortable Ausgangslage.
Der Kapitaldienstdeckungsgrad (DSCR) auf den Bankkredit (€700k, 5 %, 10 Jahre → ~€89k/Jahr): Bei einem prognostizierten EBITDA im Jahr 2 von €577k ist der DSCR weit über 1,5x. Auch in einem Stressszenario mit 20 % niedrigerer Auslastung bleibt er über 1,2x.
---
## Das persönliche Risiko: Bürgschaften offen ansprechen
Steht die Fremdkapitalstruktur, bleibt eine Frage, die in fast jedem Finanzierungsgespräch zu spät gestellt wird — und die zu oft erst auf dem Konditionenblatt der Bank auftaucht.
Banken werden für eine Padelhalle, die eine eigenständige Projektgesellschaft ist, fast immer eine **persönliche Bürgschaft** des Gründers fordern. Das bedeutet: Ihre privaten Vermögenswerte — Eigenheim, Ersparnisse, Beteiligungen — haften im Zweifelsfall.
Es gibt drei Wege, dieses Risiko zu begrenzen:
1. **Bürgschaftsbanken (Kreditgarantiegemeinschaften)**: In jedem Bundesland gibt es eine Bürgschaftsbank, die bis zu 80 % einer Bürgschaft übernehmen kann. Das reduziert die persönliche Exposition erheblich. Antrag läuft parallel zum Bankkredit.
2. **KfW-Haftungsfreistellung**: Bei bestimmten KfW-Programmen übernimmt die KfW einen Teil des Bankrisikos — das verringert den Bürgschaftsbedarf der Hausbank.
3. **Beteiligungsgesellschaften**: Stille Beteiligung eines Co-Investors, der Eigenkapital einbringt und damit den Bankkredit und Ihre persönliche Bürgschaft reduziert.
**Was in keinem Businessplan fehlen darf:** Eine explizite Darstellung der Bürgschaftssituation. Banken schätzen Gründer, die dieses Risiko klar verstehen und adressieren — nicht diejenigen, die es verdrängen.
---
## Zehn praktische Schritte zur Finanzierung
1. **Businessplan und Finanzmodell fertigstellen** — ohne belastbare Zahlen kein Bankgespräch
2. **Hausbank ansprechen** — am besten die, bei der Ihr bestehendes Konto liegt
3. **KfW-Antrag via Hausbank stellen** — die Hausbank entscheidet, welche Programme geeignet sind
4. **Landesförderbank parallel prüfen** — viele Gründer nutzen die Kombi aus KfW und Landesförderung
5. **Bürgschaftsbank kontaktieren** — falls Eigenkapital knapp ist oder persönliche Bürgschaft minimiert werden soll
6. **Steuerberater einbeziehen** — Rechtsformwahl (GmbH vs. GmbH & Co. KG), Vorsteuerabzug, Abschreibungsstruktur
7. **Mehrere Banken parallel ansprechen** — kein Exklusivgespräch, Konditionen vergleichen
8. **Eigenkapitalnachweis vorbereiten** — Banken wollen die Herkunft der Eigenkapitalmittel nachweisbar sehen
9. **Förderantrag früh stellen** — KfW und Landesförderung erfordern Antrag **vor** Baubeginn
10. **Zusageschreiben der Bank sichern** — bevor Sie den Mietvertrag oder den Kaufvertrag für das Grundstück unterzeichnen
---
## Fazit
Die Finanzierung einer Padelhalle ist lösbar — aber nur mit der richtigen Vorbereitung. Die Kombination aus Eigenkapital, ERP-Nachrangkapital, Landesförderung und Bankdarlehen ist der realistische Weg für die meisten Projekte zwischen €1,0M und €1,5M. Das ERP-Kapital für Gründung ist dabei das am häufigsten übersehene Instrument mit der höchsten Hebelwirkung.
Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell, das die Rentabilität Ihrer spezifischen Halle — nach Standort, Preismodell und Finanzierungsstruktur — belastbar demonstriert.
[scenario:padel-halle-6-courts:full]
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,198 @@
---
title: "Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung"
slug: padel-halle-kosten
language: de
url_path: /padel-halle-kosten
meta_description: "Was kostet eine Padel Halle wirklich? CAPEX €930k1,9M, Mietpreise nach Stadt, Betriebskosten und ROI-Berechnung mit echten Marktdaten für Deutschland 2026."
cornerstone: C2
---
# Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung
Wer eine Padelhalle plant, bekommt auf die Kostenfrage zunächst eine frustrierende Antwort: „Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubstanz über Platztechnik und Ausstattung bis hin zu Betriebskosten, Standortmieten und einer belastbaren 3-Jahres-Ergebnisprognose. Alle Zahlen basieren auf realen deutschen Marktdaten aus 2025/2026. Das Ziel: Sie sollen nach der Lektüre in der Lage sein, eine erste realistische Wirtschaftlichkeitsrechnung für Ihre konkrete Situation aufzustellen — und wissen, welche Fragen Sie Ihrer Bank stellen müssen.
---
## Die Wahrheit über die Kostenbandbreite
Warum liegen €930.000 und €1,9 Millionen so weit auseinander? Der größte Einzeltreiber ist der bauliche Aufwand. Wer eine bestehende Gewerbehalle — etwa einen ehemaligen Produktions- oder Logistikbau — kostengünstig anmieten und mit minimalem Umbau bespielen kann, landet am unteren Ende der Spanne. Wer dagegen auf grüner Wiese baut oder ein Gebäude von Grund auf saniert, zahlt entsprechend mehr.
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in vergleichbaren Marktsegmenten 4060 % mehr als in Leipzig oder Kassel — an den Extremen fällt der Abstand erheblich größer aus. Das schlägt sich nicht nur in der laufenden OPEX nieder, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
Realistischer Planungsansatz für eine **6-Court-Innenhalle** mit solider Ausstattung: **€1,21,5 Millionen Gesamtinvestition**. Wer mit deutlich weniger kalkuliert, unterschätzt in der Regel einen der drei teuersten Posten: Bau/Umbau, Lüftungstechnik oder den Kapitalpuffer für den Anlauf.
---
## Komplette Investitionskostenübersicht (6 Courts, Deutschland 2026)
Die folgende Tabelle zeigt die typischen Bandbreiten für eine sechsstellige Innenanlage. Darunter finden Sie für jeden Posten eine kurze Einordnung, wo die Varianz entsteht.
| Kostenposition | Bandbreite |
|---|---|
| Mietkaution oder Grundstück | €50.000€200.000 |
| Bau / Umbau | €400.000€800.000 |
| 6 Padelcourts (montiert) | €180.000€300.000 |
| Beleuchtung (LED, 500 Lux/Court) | €30.000€60.000 |
| Lüftung / Klimatisierung (HVAC) | €50.000€120.000 |
| Umkleiden, Empfang, Lounge | €80.000€150.000 |
| IT, Buchungssystem, Zugangskontrolle | €15.000€30.000 |
| Mobiliar, Equipment, Pro-Shop-Ware | €20.000€40.000 |
| Architekt, Genehmigungen, Rechts- und Beratungskosten | €40.000€80.000 |
| Marketing vor der Eröffnung | €15.000€30.000 |
| Betriebsmittelreserve | €50.000€100.000 |
| **Gesamt** | **€930.000€1.910.000** |
**Bau und Umbau (€400k€800k)** ist mit Abstand der volatilste Posten. Ob Sie in eine Bestandshalle einziehen, die bereits die nötige Deckenhöhe (mindestens 89 m lichte Höhe) mitbringt, oder ob Statik, Dachkonstruktion und Entwässerung angepasst werden müssen — das entscheidet oft über €200.000 in die eine oder andere Richtung. Lassen Sie diesen Posten durch einen lokalen Bauunternehmer mit Hallenerfahrung früh einschätzen.
**Die Courts selbst (€30.000€50.000 pro Court)** variieren vor allem nach Hersteller und Glasqualität. Panorama-Courts mit vollverglaster Rückwand kosten mehr als Standard-Courts mit Kombikonstruktion. Auf die Gesamtanlage bezogen ist der Unterschied jedoch kleiner als er scheint: Six Courts für €180k oder €300k — das ist bei einem Gesamtprojekt von €1,2M eine Differenz von rund 10 %.
**HVAC (€50k€120k)** wird systematisch unterschätzt. In einer geschlossenen Halle mit sechs aktiven Courts und 60+ gleichzeitigen Spielern entsteht erhebliche thermische Last und Feuchtigkeit. Wer hier spart, riskiert Beschwerden, Bauschäden und hohe Folgekosten. Kalkulieren Sie eher an der oberen Grenze — zumal eine gut ausgelegte Anlage auch den Energieverbrauch dauerhaft senkt.
**Betriebsmittelreserve (€50k€100k)** ist kein "nice to have". In den ersten sechs bis zwölf Monaten liegen die Einnahmen unter dem Regelbetrieb, während Personal und Miete bereits voll anfallen. Wer diese Rücklage nicht einplant, gerät schnell unter Liquiditätsdruck, bevor das Geschäft überhaupt Fahrt aufnimmt.
---
## Hallenmiete in Deutschland: Was Sie nach Standort zahlen
Bau und Courts binden den größten Teil des Startkapitals. Was über die langfristige Wirtschaftlichkeit entscheidet, zahlen Sie monatlich: die Miete.
Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) eine Fläche von **1.500 bis 2.500 qm**. Auf Basis aktueller Gewerberaummieten für Industrie- und Hallenflächen in deutschen Städten ergibt sich folgende Einschätzung:
| Stadt | Miete €/qm/Monat | Typische Monatsmiete (2.000 qm) |
|---|---|---|
| München | €1014 | €20.000€28.000 |
| Berlin | €812 | €16.000€24.000 |
| Frankfurt | €811 | €16.000€22.000 |
| Hamburg | €710 | €14.000€20.000 |
| Düsseldorf | €811 | €16.000€22.000 |
| Köln | €69 | €12.000€18.000 |
| Stuttgart | €710 | €14.000€20.000 |
| Leipzig | €47 | €8.000€14.000 |
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt. Für München oder Berlin kalkulieren Sie mit den Werten aus der Stadtübersicht oben — und passen Sie die Erlösannahme entsprechend an.
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 510 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Ein langfristiger Mietvertrag mit indexierter Staffelung ist für die Bank ein echtes Positivsignal — er macht aus unsicheren künftigen Einnahmen etwas, das im Kreditbescheid wie planbarer Cashflow aussieht.
---
## Platzbuchungspreise: Was der Markt trägt
Das Ertragspotenzial folgt der Standortlogik ähnlich eng wie die Mietkosten. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
| Stadt | Nebenzeiten (€/Std.) | Hauptzeiten (€/Std.) | Datenbasis |
|---|---|---|---|
| Berlin | €33 | €46 | Hoch |
| München | €30 | €42 | Schätzung |
| Düsseldorf | €30 | €42 | Schätzung |
| Hamburg | €26 | €36 | Mittel |
| Stuttgart | €26 | €38 | Mittel |
| Frankfurt | €24 | €28 | Hoch |
| Köln | €22 | €27 | Hoch |
| Leipzig | €18 | €26 | Schätzung |
Der Playtomic Global Padel Report 2025 liefert dazu eine interessante Benchmark: Der deutsche Durchschnitt-GMV je Court stieg im Jahresvergleich um **48 % auf €4.000/Monat** — bei einer Auslastung von rund 30 % entspricht das einem Blended-Stundensatz von etwa **€30**. Das deckt sich gut mit den obigen Stadtdaten für B-Lagen und kleinere Märkte.
Für die Ertragsmodellierung in diesem Artikel rechnen wir mit einem blended Durchschnittspreis von **€45/Stunde** — das ist ein Mischpreis aus Neben- und Hauptzeiten und entspricht einem gut positionierten Angebot in einer Stadt der oberen Hälfte dieser Liste.
---
## Betriebskosten (OPEX)
Laufende Kosten werden beim Business-Plan häufig zu optimistisch angesetzt. Die folgende Übersicht zeigt realistische Werte für eine 6-Court-Halle im deutschen Markt:
| Kostenposition | Jahr 1 | Jahr 2 | Jahr 3 |
|---|---|---|---|
| Miete / Pacht | €120.000 | €123.000 | €127.000 |
| Personal (58 VZÄ) | €200.000 | €220.000 | €235.000 |
| Energie (Beleuchtung, HVAC) | €45.000 | €50.000 | €55.000 |
| Wartung und Reparaturen | €20.000 | €25.000 | €30.000 |
| Marketing | €40.000 | €30.000 | €25.000 |
| Versicherungen | €12.000 | €12.000 | €13.000 |
| Buchungssystem / IT | €8.000 | €8.000 | €9.000 |
| Wareneinsatz (F&B, Shop) | €25.000 | €40.000 | €48.000 |
| Verwaltung, Buchhaltung, Recht | €20.000 | €22.000 | €24.000 |
| **OPEX gesamt** | **€490.000** | **€530.000** | **€566.000** |
**Personal** ist der größte Einzelposten und wird am häufigsten falsch angesetzt. Fünf Vollzeitäquivalente sind das Minimum für einen vernünftigen Betrieb — Empfang, Platzservice, Trainer, Verwaltung. Mit Arbeitgeberanteilen zur Sozialversicherung und realistischen deutschen Lohnniveaus sind €200.000 im ersten Jahr eher knapp als großzügig kalkuliert.
**Energie** ist ein Posten, der je nach Lage und technischer Ausstattung stark schwanken kann. Ältere Gewerbegebäude mit schlechter Dämmung und ineffizienter Lüftung können deutlich über den hier angesetzten Werten liegen. Lassen Sie vor dem Mietvertrag einen Energieberater die Hülle bewerten.
**Marketing** ist im ersten Jahr bewusst höher angesetzt — Pre-Launch, Eröffnungskampagne, erste Ligakooperationen. Ab Jahr 2 trägt die Community zunehmend selbst, wenn das Produkt stimmt.
---
## 3-Jahres-Ergebnisvorschau
[scenario:padel-halle-6-courts:full]
Die folgende Projektion basiert auf einem blended Stundenpreis von €45 bei 6 Courts und einem Betrieb von täglich 14 Betriebsstunden (822 Uhr, 365 Tage).
| Ertragsquelle | Jahr 1 (45 % Ausl.) | Jahr 2 (60 % Ausl.) | Jahr 3 (70 % Ausl.) |
|---|---|---|---|
| Platzvermietung | €665.000 | €887.000 | €1.035.000 |
| Coaching & Akademie | €60.000 | €90.000 | €120.000 |
| Gastronomie / Bar | €40.000 | €65.000 | €80.000 |
| Pro Shop | €15.000 | €25.000 | €30.000 |
| Events & Corporate | €20.000 | €40.000 | €60.000 |
| **Gesamtumsatz** | **€800.000** | **€1.107.000** | **€1.325.000** |
| **OPEX gesamt** | **€490.000** | **€530.000** | **€566.000** |
| **EBITDA** | **€310.000** | **€577.000** | **€759.000** |
Zur Einordnung: Die Platzvermietung macht im ersten Jahr rund 83 % des Umsatzes aus. Mit wachsender Community gewinnen Coaching, Events und Gastronomie an Gewicht — was die Marge verbessert, weil diese Angebote oft höhere Deckungsbeiträge haben als reiner Platzbetrieb.
Die EBITDA-Margen von 39 % (J1) über 52 % (J2) bis 57 % (J3) liegen im oberen Bereich dessen, was im europäischen Padel-Sektor dokumentiert ist. Sie setzen voraus, dass Personal sauber dimensioniert und das Energiekonzept vernünftig ist — keine heroischen Annahmen, aber auch kein Spielraum für Schlampigkeit.
---
## Wirtschaftlichkeit: Die entscheidenden Kennzahlen
Bevor Sie einen Business-Plan zur Bank tragen, sollten Sie diese fünf Kennzahlen selbst rechnen können — und die Sensitivitäten verstehen.
**Amortisationsdauer: 35 Jahre**
Bei einem Gesamtprojekt von €1,4M (Midpoint) und einem Free Cashflow von €200k (J1) bis €650k+ (J3) ergibt sich je nach Finanzierungsstruktur eine Rückzahlungsdauer von 35 Jahren für das eingesetzte Eigenkapital. Das ist für eine Immobilien-Infrastruktur-Investition im Freizeitsektor sehr attraktiv.
**Break-even-Auslastung: 3540 %**
Unterhalb von 35 % Auslastung deckt der Betrieb in der Regel nicht seine laufenden Kosten. Das klingt niedrig — ist es in der Praxis aber nicht zwingend. In der Anlaufphase (Monate 16) sind 2530 % realistisch; das setzt voraus, dass die Betriebsmittelreserve steht.
**Zielumsatz je Court: €150.000+ p.a. bei Reife**
Jahr 3 in obiger Projektion: €1,035M Platzvermietung ÷ 6 Courts = €172.500/Court. Das entspricht einem gut etablierten Angebot — erreichbar, aber kein Selbstläufer.
**Eigenkapitalrendite (Cash-on-Cash): 60 %+ ab Jahr 3**
Bei einer Eigenkapitalbeteiligung von €500.000 und einem bereinigten Free Cashflow von €300.000+ in Jahr 3 ergibt sich eine Cash-on-Cash-Rendite von über 60 %. Das setzt eine saubere Finanzierungsstruktur mit Fremdkapital voraus — mehr dazu im nächsten Abschnitt.
**Schuldendienst: ~€102.000/Jahr**
Bei einem Darlehen von €800.000 (z. B. KfW oder Hausbank), 5 % Zinsen und 10 Jahren Laufzeit ergibt sich ein jährlicher Kapitaldienst von rund €102.000. Dieser ist in den EBITDA-Zahlen noch nicht abgezogen — er sollte im Jahr 1 bereits komfortabel gedeckt sein.
---
## Was Banken wirklich wollen
Eine Padelhalle ist für die meisten Bankberater unbekanntes Terrain. Auslastungsquoten und Erlöse pro Court sind keine Größen, mit denen Kreditausschüsse täglich arbeiten — das ist Ihr Vorteil. Wer mit sauberen Zahlen und strukturierter Dokumentation ins Gespräch geht, fällt sofort positiv auf. Was den Kreditausschuss bewegt, ist nicht die Begeisterung für den Sport, sondern die Belastbarkeit der Unterlagen.
**Debt Service Coverage Ratio (DSCR) 1,21,5x:** Die Bank will sehen, dass Ihr operativer Cashflow den Schuldendienst mit einem Puffer von 2050 % abdeckt. Mit einem EBITDA von €310.000 im ersten Jahr und einem Schuldendienst von €102.000 liegt der DSCR bei 3,0 — auf dem Papier sehr solide. Aber: Banken werden nachfragen, wie empfindlich dieses Ergebnis auf niedrigere Auslastung reagiert.
**Sensitivitätsanalyse:** Zeigen Sie, was bei 35 % Auslastung (Break-even) und bei 25 % Auslastung passiert. Das signalisiert, dass Sie die Downside kennen und quantifiziert haben.
**3-Jahres-Projektionen mit monatlicher Cashflow-Planung im ersten Jahr:** Gerade die ersten 12 Monate wollen Banken auf Monatsbasis sehen — nicht weil sie den genauen Zahlen glauben, sondern weil es zeigt, dass Sie den Anlaufbetrieb durchdacht haben.
**Objektgutachten und Mietvertrag:** Ein unterschriebener Mietvertrag mit klaren Konditionen ist für das Kreditgespräch praktisch Pflicht. Ohne ihn bleibt die Kreditwürdigkeitsprüfung im Ungefähren.
Wie Sie einen vollständigen Businessplan strukturieren und welche Unterlagen Banken im Detail verlangen, lesen Sie im separaten Artikel zu Businessplan und Finanzierungsoptionen für Padelhallen.
---
## Fazit
Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,9 Millionen, realistischer Mittelpunkt €1,21,5 Millionen. Wer diese Zahlen kennt und versteht, wo die Hebel sitzen, kann daraus ein belastbares Investitionsmodell bauen. Wer mit Schätzungen aus zweiter Hand ins Bankgespräch geht, verliert Zeit und Glaubwürdigkeit.
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,227 @@
---
title: "Die 14 Risiken einer Padel Halle, die Investoren unterschätzen"
slug: padel-halle-risiken
language: de
url_path: /padel-halle-risiken
meta_description: "Konkurrenz, Baukostenüberschreitungen, Trend-Risiko, persönliche Bürgschaft: Die wichtigsten Risiken beim Bau einer Padelhalle ehrlich bewertet."
cornerstone: C7
---
# Die 14 Risiken einer Padel Halle, die Investoren unterschätzen
Wer sich mit dem Gedanken trägt, eine Padelhalle zu bauen, hat meistens schon eine Hochrechnung gemacht. Und die sieht gut aus: Buchungsauslastung von 65 bis 70 Prozent, fünf oder sechs Courts, ein paar Premiumstunden für Corporate-Kunden — und schon rechnet sich das Projekt auf dem Papier.
Das Problem ist nicht die Hochrechnung. Das Problem ist, was in der Hochrechnung fehlt.
Dieser Artikel zeigt Ihnen die 14 Risiken, über die in Investorenrunden zu wenig gesprochen wird. Nicht um Sie abzuschrecken — Padelhallen können sehr gut funktionierende Unternehmen sein. Sondern weil die Projekte scheitern, die diese Risiken nicht einplanen. Ein ehrlicher Blick auf die Downside schützt Ihr Kapital und gibt Ihnen eine solidere Grundlage für die Entscheidung.
---
## Die 14 Risiken im Überblick
| # | Risiko | Kategorie | Schwere |
|---|--------|-----------|---------|
| 1 | Trend-/Modeerscheinung | Strategisch | <span class="severity severity--high">Hoch</span> |
| 2 | Baukostenüberschreitungen | Bau & Entwicklung | <span class="severity severity--high">Hoch</span> |
| 3 | Verzögerungen während des Baus | Bau & Entwicklung | <span class="severity severity--high">Hoch</span> |
| 4 | Vermieterproblem: Verkauf, Insolvenz, keine Verlängerung | Immobilie & Mietvertrag | <span class="severity severity--high">Hoch</span> |
| 5 | Neue Konkurrenz im Einzugsgebiet | Wettbewerb | <span class="severity severity--medium-high">MittelHoch</span> |
| 6 | Schlüsselpersonen-Abhängigkeit | Betrieb | <span class="severity severity--medium">Mittel</span> |
| 7 | Fachkräftemangel und Lohndruck | Betrieb | <span class="severity severity--medium">Mittel</span> |
| 8 | Instandhaltungszyklen für Belag, Glas, Kunstrasen | Betrieb | <span class="severity severity--medium">Mittel</span> |
| 9 | Energiepreisvolatilität | Finanzen | <span class="severity severity--medium">Mittel</span> |
| 10 | Zinsänderungsrisiko | Finanzen | <span class="severity severity--medium">Mittel</span> |
| 11 | Persönliche Bürgschaft | Finanzen | <span class="severity severity--high">Hoch</span> |
| 12 | Kundenkonzentration | Finanzen | <span class="severity severity--medium">Mittel</span> |
| 13 | Lärmbeschwerden und behördliche Auflagen | Regulatorisch & Rechtlich | <span class="severity severity--medium">Mittel</span> |
| 14 | Buchungsplattform-Abhängigkeit | Regulatorisch & Rechtlich | <span class="severity severity--low-medium">NiedrigMittel</span> |
---
## 1. Trend-Risiko: Ist Padel in 10 Jahren noch relevant?
Das ist das Risiko, über das die wenigsten laut nachdenken wollen — und das gleichzeitig das gefährlichste ist.
Padel boomt. In Deutschland wächst die Spielerzahl seit sechs Jahren konsistent. Courts sind ausgebucht, Wartelisten sind normal, und das Medieninteresse steigt. Aber: Sie bauen nicht für die nächsten zwei Jahre. Sie bauen für die nächsten zehn bis fünfzehn. Und das ist eine ganz andere Wette.
Squash in den 1980er Jahren folgte einem ähnlichen Muster: Boom, Infrastruktur-Boom, dann langsam abbauende Nachfrage. Wer 1987 eine Squashhalle eröffnet hat, hat das gemerkt.
Der Gegenargument ist real: Padel erfordert fest eingebaute Courts. Diese Infrastruktur erzeugt eine Klebrigkeit, die Squash nie hatte — wer einmal regelmäßig spielt, sucht eine Anlage, fährt dorthin, bucht wieder. Und die Spielerzahlen in Deutschland zeigen bislang keinen Plateaueffekt.
Trotzdem gilt: Wenn Ihre Auslastung in Jahr fünf von 65 auf 35 Prozent fällt, weil der Hype abklingt, bricht Ihr Modell. Dieses Szenario ist kaum absicherbar — aber es lässt sich zumindest in die Sensitivitätsanalyse einbauen. Was wäre die Konsequenz einer Auslastung von 40 Prozent über zwei Jahre? Können Sie das überstehen?
---
## 2 & 3. Bau- und Entwicklungsrisiken: Überschreitungen sind die Norm
Sportanlagen werden so gut wie nie zum ursprünglichen Budget fertiggestellt. Kostensteigerungen von 15 bis 30 Prozent gegenüber dem ersten Angebot sind in der Branche keine Ausnahme — sie sind der Regelfall.
Hinzu kommen Bauverzögerungen. Jeder Monat, in dem eine Halle nicht eröffnet ist, ist ein Monat, in dem Sie Miete, Zinsen und möglicherweise bereits vertraglich gebundenes Personal bezahlen, ohne einen Euro Umsatz zu machen. Bei einer mittelgroßen Halle mit sechs Courts und einem monatlichen Fixkostenblock von 30.000 bis 50.000 Euro läppert sich das schnell zusammen.
**Was das in der Praxis bedeutet:**
- Mindestens 15 bis 20 Prozent Puffer auf das Baubudget einkalkulieren — nicht als Wunsch, sondern als Pflicht
- Wo möglich Festpreisverträge aushandeln; die Risikoverteilung im Vertrag lesen, nicht nur den Preis
- Im Finanzmodell explizit ein Verzögerungsszenario von drei bis sechs Monaten durchrechnen
---
## 4. Immobilien- und Mietvertragsrisiken: Wessen Gebäude ist das eigentlich?
Wer eine Halle miet- statt eigentumsbasiert betreibt, investiert oft 500.000 Euro und mehr in ein Gebäude, das ihm nicht gehört. Das ist per se kein Problem — aber es ist ein Risiko, das aktiv gemanagt werden muss.
Was passiert, wenn der Vermieter das Objekt verkauft und der Käufer andere Pläne hat? Was passiert, wenn der Vermieter insolvent wird und der Insolvenzverwalter den Mietvertrag kündigt? Was passiert, wenn nach 10 Jahren keine Verlängerung angeboten wird — und Ihre Investitionen vollständig abgeschrieben sind, aber Sie Ihren Geschäftsbetrieb neu aufsetzen müssten?
**Mindestanforderungen an einen soliden Mietvertrag:**
- Mindestlaufzeit von 15 Jahren, besser mehr
- Verlängerungsoptionen mit klar definierten Konditionen
- Entschädigungsklauseln für Mietereinbauten bei vorzeitiger Kündigung durch den Vermieter
- Vorkaufsrecht oder Zustimmungsvorbehalt bei Eigentümerwechsel, wenn möglich
Lassen Sie sich hier von einem auf Gewerberecht spezialisierten Anwalt beraten. Die paar Tausend Euro Rechtsberatung sind eine der rentabelsten Ausgaben im gesamten Projekt.
---
## 5. Wettbewerbsrisiko: Ihr Erfolg zieht Konkurrenz an
Volle Courts und Wartelisten sind gut — aber sie sind auch ein Signal an andere Investoren: Hier ist Geld zu verdienen.
Wenn in Jahr drei ein neuer Wettbewerber 10 Fahrminuten entfernt aufmacht, ist Ihre Auslastung unter Druck. Aus 70 Prozent werden vielleicht 50. Das klingt nicht dramatisch, kann aber — abhängig von Ihrer Kostenstruktur — den Unterschied zwischen schwarzen und roten Zahlen bedeuten.
Einen echten Burggraben gibt es im Padel-Geschäft kaum. Keine Patente, keine Netzwerkeffekte, keine Wechselkosten. Was bleibt, ist: Standort, Gemeinschaft, Servicequalität und die Beziehung zu Stammkunden. Das sind reale Vorteile — aber sie müssen aktiv aufgebaut und gepflegt werden.
**Rechnen Sie das durch.** Modellieren Sie im Businessplan explizit das Szenario „neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
---
## 68. Operative Risiken: Drei Themen, die oft unterschätzt werden
### Schlüsselpersonen-Abhängigkeit
Viele Padelhallen sind anfangs stark von einer Person abhängig: dem Gründer, der alles organisiert — oder dem Tennistrainer, der sein Netzwerk mitbringt. Was passiert, wenn diese Person ausscheidet?
Abhilfe: Frühzeitig Prozesse dokumentieren, Führungsverantwortung auf mehrere Schultern verteilen, keine unlösbaren Bindungen an einzelne Personen schaffen.
### Mitarbeiterbindung
Gute Facility-Manager, Coaches mit einer echten Affinität zum Gast und zuverlässiges Empfangspersonal sind auf dem deutschen Arbeitsmarkt nicht leicht zu finden — und noch schwerer zu halten. Lohndruck, Wettbewerb aus anderen Branchen und die physisch anspruchsvolle Natur von Schichtarbeit sind reale Herausforderungen. Kalkulieren Sie Fluktuation ein.
### Instandhaltungszyklen
Courts sind keine Set-and-forget-Investition. Kunstrasenbelag hat eine Lebensdauer von fünf bis acht Jahren. Glaswände und Pfosten brauchen regelmäßige Inspektion und Ersatz. Wer diese Kosten nicht in die laufende Planung einrechnet, erlebt in Jahr sechs eine unangenehme Überraschung. Budgetieren Sie einen Rückstellungsbetrag pro Court und Jahr — konservativ angesetzt deutlich über null.
---
## 912. Finanzrisiken: Die vier stillen Killer
### Energiepreisvolatilität
Innenhallen verbrauchen erheblich Energie: Beleuchtung, Heizung/Kühlung, Lüftung. Wer 2022 zu Spotmarktpreisen eingekauft hat, weiß, was das bedeuten kann. Prüfen Sie, ob Festpreisverträge verfügbar sind, und bewerten Sie LED-Beleuchtung sowie effiziente Klimaanlage nicht nur als Kostensenkung, sondern als Risikoabsicherung.
### Zinsänderungsrisiko
Zwischen Planungsstart und Kreditauszahlung können sechs bis zwölf Monate liegen. In diesem Zeitraum kann sich das Zinsniveau verschieben. Bei einem Fremdkapitalanteil von 60 Prozent auf ein Gesamtinvestment von 1,5 Millionen Euro bedeuten 200 Basispunkte Zinserhöhung rund 18.000 Euro mehr Zinslast pro Jahr. Sichern Sie Ihren Zinssatz frühzeitig ab oder rechnen Sie explizit mit einem Stresstest bei plus zwei Prozentpunkten.
### Kundenkonzentration
Wenn 30 Prozent Ihres Umsatzes von drei oder vier Unternehmenskunden kommen, die ihre Mitarbeitenden schicken: Das fühlt sich gut an — bis einer der Kunden das Budget kürzt oder intern umstrukturiert. Diversifizierung der Einnahmebasis ist kein Luxus, sondern Risikomanagement.
### Inflation und Preissetzungsmacht
Ihre Kosten steigen jedes Jahr um drei bis fünf Prozent. Können Sie diese Steigerung auf den Buchungspreis überwälzen, ohne Auslastung zu verlieren? In einem gesättigten Markt mit mehreren Anbietern wird das schwieriger. Die Frage der Preissetzungsmacht sollte Teil jeder Marktanalyse sein.
---
## Sonderbox: Persönliche Bürgschaft — das unterschätzte Risiko Nr. 1
<div class="article-callout article-callout--warning">
<div class="article-callout__body">
<span class="article-callout__title">Dieses Thema wird in fast jedem Gespräch über Padelhallen-Investitionen ausgelassen. Das ist ein Fehler.</span>
<p>Banken, die einer Einzelanlage ohne Konzernrückhalt Kapital bereitstellen, verlangen in der Praxis fast immer eine persönliche Bürgschaft des oder der Hauptgesellschafter.</p>
</div>
</div>
Das bedeutet: Wenn das Unternehmen in Zahlungsschwierigkeiten gerät, haftet nicht die GmbH allein — Sie haften persönlich. Mit dem Eigenheim. Mit dem Ersparten. Mit dem Depot.
Die Struktur sieht dann typischerweise so aus:
- Sie gründen eine GmbH und halten die Anteile. Die GmbH nimmt das Darlehen auf.
- Die Bank gewährt das Darlehen, verlangt aber Ihre persönliche Bürgschaft als Sicherheit — unbeschränkt oder bis zu einem bestimmten Betrag.
- Wenn die GmbH insolvent geht, greift die Bank auf Sie persönlich zu.
Was bedeutet das konkret? Bei einem Bankdarlehen von 800.000 Euro mit persönlicher Bürgschaft setzen Sie Ihr gesamtes Privatvermögen als Sicherheit ein. Die beschränkte Haftung der GmbH ist in dieser Konstellation für Sie als Gesellschafter-Bürge weitgehend illusorisch.
**Was Sie tun können:**
1. **Vor der Unterschrift:** Lassen Sie den Bürgschaftsumfang von einem Anwalt prüfen. Beschränkte Bürgschaft bis zu einem definierten Betrag ist oft verhandelbar.
2. **Absicherung im Privatvermögen:** Klären Sie frühzeitig mit einem Vermögensberater, welche privaten Vermögenswerte ggf. schutzwürdig sind.
3. **Stresstest:** Beantworten Sie ehrlich: Was passiert, wenn die Halle in Jahr zwei schließen muss? Können Sie das Worst-Case-Szenario finanziell und emotional tragen?
4. **Mitbürgen:** Wenn mehrere Gesellschafter vorhanden sind, klären Sie intern, wer bürgt und zu welchen Anteilen.
Kein anderes Risiko in diesem Artikel ist so real und so persönlich wie dieses. Gehen Sie es mit offenen Augen an.
---
## 1314. Regulatorische und rechtliche Risiken
### Lärmbeschwerden
Padel ist laut. Bälle auf Glaswänden erzeugen einen charakteristischen Klang, der in der Nähe von Wohnbebauung schnell zur Quelle von Nachbarschaftskonflikten wird. Kommunen können Betriebszeitrestriktionen erlassen oder Schallschutzauflagen verhängen, die Nachrüstungen erfordern.
**Vor Vertragsunterzeichnung:** Prüfen Sie die Lärmschutzauflagen für den geplanten Standort. Holen Sie eine Stellungnahme ein, ob das Nutzungskonzept im Rahmen der geltenden TA Lärm genehmigungsfähig ist. Eine professionelle Schallschutzanalyse ist kein Luxus.
### Buchungsplattform-Abhängigkeit
Playtomic ist in Deutschland der dominierende Anbieter. Das ist praktisch — und ein Konzentrationsrisiko. Wenn Playtomic die Kommissionsstruktur ändert, Ihren Slot im Algorithmus schlechter stellt oder Ihnen Konkurrenz über die eigene Plattform schickt, sind Sie davon unmittelbar betroffen.
Mittel- bis langfristig sollten Sie eine eigene Buchungsfähigkeit aufbauen — zumindest als Fallback. Das schützt Ihre Kundenbeziehungen und Ihre Marge.
---
## Was gutes Risikomanagement in der Praxis bedeutet
Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfolgreich sind, tun Folgendes:
<div class="article-cards">
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Schlechte Szenarien zuerst durchrechnen</span>
<p class="article-card__body">Ein Businessplan, der nur das Base-Case zeigt, ist kein Werkzeug — er ist Wunschdenken. Was passiert bei 40 Prozent Auslastung? Bei sechs Monaten Bauverzug? Bei einem neuen Wettbewerber in Jahr drei?</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Puffer als betriebliche Notwendigkeit</span>
<p class="article-card__body">Liquide Reserven von mindestens sechs Monaten Fixkosten sind kein Luxus, sondern Pflicht. Baukostenpuffer ist eine Budgetlinie — kein optionales Polster.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Verträge von Anfang an absichern</span>
<p class="article-card__body">Mietvertrag, Finanzierungskonditionen, Bürgschaftsumfang. Die Kosten für gute Rechts- und Finanzberatung in der Planungsphase sind verglichen mit dem Downside verschwindend gering.</p>
</div>
</div>
<div class="article-card article-card--success">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Für Wettbewerb planen</span>
<p class="article-card__body">Nicht indem man auf keine Konkurrenz hofft, sondern indem man ein Produkt aufbaut, das Stammkunden bindet — durch Qualität, Community und Dienstleistungsqualität.</p>
</div>
</div>
</div>
---
## Die Padelnomics-Investitionsrechnung
Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,183 @@
---
title: "Wo baut man eine Padel Halle? Standortanalyse mit Daten"
slug: padel-standort-analyse
language: de
url_path: /de/blog/padel-standort-analyse
meta_description: "8 Kriterien für die optimale Padel-Standortentscheidung: Einzugsgebiet, Wettbewerb, Sichtbarkeit, Mietkosten, Baugenehmigung mit Daten statt Bauchgefühl."
cornerstone: C5
---
# Wo baut man eine Padel Halle? Standortanalyse mit Daten
Die Standortentscheidung ist die einzige Entscheidung im Lebenszyklus einer Padelhalle, die sich nicht korrigieren lässt. Schlechte Preisgestaltung kann angepasst werden. Ein schwaches Marketingkonzept kann überarbeitet werden. Ein Court-Belag, der sich als falsche Wahl erweist, kann nach einigen Jahren ausgetauscht werden. Der Standort nicht. Wer diesen Schritt mit Bauchgefühl oder nach dem Kriterium "günstige Miete gefunden" trifft, trägt ein strukturelles Risiko in jede Projektion, die danach kommt. Dieser Leitfaden zeigt, wie eine datenbasierte Standortentscheidung aussieht.
---
## Die 8 Kriterien der Standortanalyse
### 1. Einzugsgebietsanalyse
Bevor ein Objekt ernsthaft in Betracht gezogen wird, muss das Einzugsgebiet verstanden werden. Ausgangspunkt sind zwei Isochrone: 15-Minuten-Fahrzeit und 30-Minuten-Fahrzeit vom geplanten Standort. Innerhalb dieser Radien ist die relevante Bevölkerung zu analysieren — nicht nach Gesamtkopfzahl, sondern nach den Kennzahlen, die Padel-Nachfrage vorhersagen:
- **Altersstruktur**: Der Kern der Padel-Zielgruppe liegt bei 2555 Jahren. Regionen mit überalterter Bevölkerung oder sehr jungen Altersstrukturen ohne verfügbares Einkommen sind schwieriger.
- **Haushaltseinkommen**: Destatis veröffentlicht Einkommensdaten auf Kreisebene. Padel ist kein Elitenbedarfs- aber auch kein Massenmarktsport — Haushalte mit mittlerem bis gehobenem Einkommen (Netto über 3.000 Euro monatlich pro Haushalt) sind die Kernzielgruppe.
- **Erwerbsquote und Berufsstruktur**: Doppelverdiener-Haushalte, in denen beide Partner berufstätig und sportlich aktiv sind, zeigen die höchste Zahlungsbereitschaft für Hallensportarten mit festen Buchungszeiten.
- **Sportaffinität**: Gibt es bereits eine aktive Tennis- oder Squash-Community? Padel hat eine hohe Konversionsrate aus beiden Sportarten.
Ein starkes Einzugsgebiet hat: hohe Bevölkerungsdichte, Altersmedian 3045, überdurchschnittliches Haushaltseinkommen, bestehende Sportinfrastruktur (die ein vorhandenes Sportpublikum belegt).
### 2. Wettbewerbsanalyse
Welche Padelhallen gibt es bereits im Einzugsgebiet, und wie gut ausgelastet sind sie? Das ist die wichtigste Einzelfrage der Standortentscheidung.
Bestehende Padelhallen sind auf Buchungsplattformen wie Playtomic oder Matchi gelistet. Wer dort zum nächsten Wochenende schaut und die Verfügbarkeit analysiert, erhält ein unmittelbares Bild der Nachfragesituation:
- Sind die Courts zu Stoßzeiten (Werktag 1721 Uhr, Wochenende 914 Uhr) vollständig belegt? Das ist ein klares Nachfragesignal.
- Sind zu diesen Zeiten reichlich freie Slots verfügbar? Der Markt ist möglicherweise gesättigt oder noch nicht entwickelt.
Als Faustregel für den Entfernungseffekt: Eine weitere Anlage innerhalb von 5 Kilometern kostet in der Regel 1525 Prozent Auslastung. Innerhalb von 10 Kilometern sind es noch 515 Prozent. Diese Werte sind keine fixen Gesetze, aber sie geben eine realistische Grundlage für die Szenarienplanung.
Wichtig: Wettbewerb ist kein K.O.-Kriterium. In Märkten mit hoher Nachfrage und bislang unzureichendem Angebot kann ein zweiter oder dritter Standort stark funktionieren. Entscheidend ist das Verhältnis von Nachfrage zu Angebot, nicht die bloße Existenz von Wettbewerbern.
### 3. Erreichbarkeit und Parkplätze
Padel ist ein Sport, der überwiegend mit dem Auto besucht wird. Das hat praktische Konsequenzen:
**Parkplätze**: Richtwert 2 bis 3 Stellplätze pro Court. Für eine Vierercourt-Anlage bedeutet das mindestens 812 Stellplätze — plus Puffer für Coaches, Personal und gleichzeitige Buchungen in den angrenzenden Zeitfenstern. Wer hier knapp plant, hat ein operatives Problem ab dem ersten vollen Wochenend-Peak.
**ÖPNV-Anbindung**: Kein Ausschlusskriterium, aber ein echter Mehrwert. Standorte mit guter S-Bahn- oder U-Bahn-Anbindung erschließen ein deutlich breiteres Publikum — insbesondere jüngere Spieler und urbane Haushalte ohne Zweitauto.
Gewerbegebiete und Industriestandorte haben oft gute PKW-Zugänglichkeit, aber keine Anbindung an den Nahverkehr. Das ist für viele Padelhallen akzeptabel, sollte aber in die Zielgruppenüberlegung eingepreist werden.
### 4. Sichtbarkeit und Lage
Ein Standort an einer Hauptverkehrsstraße oder in einem Gewerbegebiet mit hohem Durchgangsverkehr generiert passive Bekanntheit — Menschen sehen die Halle und nehmen sie wahr, ohne gezielt danach gesucht zu haben. Das reduziert den Marketingaufwand beim Aufbau.
Ein Standort in einem versteckten Gewerbegebiet, abseits von Hauptstraßen und ohne Außenwerbewirkung, funktioniert auch — aber er verlangt 3040 Prozent mehr Marketingaufwand in der Anlaufphase und eine stärkere digitale Präsenz, um den fehlenden passiven Traffic zu kompensieren.
Gleichzeitig gilt: Sichtbarkeit hat ihren Preis. Premium-Lagen an Hauptstraßen oder in Gewerbecentern kosten oft das Doppelte pro Quadratmeter gegenüber vergleichbaren Flächen in Nebenlagen. Das muss im Mietkosten-Umsatz-Verhältnis gegengerechnet werden (siehe Kriterium 6).
### 5. Objekteignung für den Umbau
Padelhallen stellen spezifische bauliche Anforderungen. Nicht jedes Objekt, das groß genug ist, ist tatsächlich geeignet:
**Deckenhöhe**: Minimum 8 Meter lichte Höhe, ideal 10 Meter oder mehr. Unter 8 Metern ist Padel nur mit modifizierten Spielregeln möglich und für Turnierbetrieb nicht geeignet.
**Stützenfreiheit und Spannweiten**: Ein Standard-Padel-Court misst 20 × 10 Meter. Mit Sicherheitsabständen braucht jeder Court eine stützenfreie Fläche von circa 22 × 12 Metern. Hallen mit engem Stützenraster scheiden in der Regel aus.
**Bodenbelastung**: Padel-Courts sind nicht außergewöhnlich schwer, aber Stahlkonstruktionen für die Court-Rahmen müssen fundiert werden. Der Untergrund muss entsprechend tragfähig sein.
**Versorgungsanschlüsse**: LED-Beleuchtung für Padel (Standard: 300500 Lux auf Spielfläche) hat einen hohen Strombedarf. Der vorhandene Elektroanschluss muss das leisten können oder ausbaubar sein.
Industriehallen und Gewerbehallen aus den 1980er und 1990er Jahren in Deutschland erfüllen diese Kriterien häufig — und sind oft zu deutlich günstigeren Konditionen verfügbar als Neubauten oder Retailflächen.
### 6. Miet-Umsatz-Verhältnis
Padelhallen benötigen große Flächen: 1.500 bis 3.000 Quadratmeter für eine Anlage mit 4 bis 8 Courts inklusive Nebenräumen (Umkleiden, Lounge, Empfang, Lager). Der Mietpreis pro Quadratmeter hat daher einen überproportionalen Einfluss auf die Rentabilität.
**Richtwert**: Die jährliche Gesamtmiete sollte nicht mehr als 15 Prozent des geplanten Jahresumsatzes in Jahr 3 betragen. Ein Beispiel zur Veranschaulichung:
- Geplanter Umsatz in Jahr 3: 1,1 Millionen Euro
- Maximale nachhaltige Jahresmiete: 165.000 Euro (15%)
- Entspricht einer Monatsmiete von 13.750 Euro
- Bei 1.500 Quadratmetern: ca. 9,20 Euro pro Quadratmeter und Monat
Liegt die Angebotsmiete signifikant über diesem Wert, ist der Standort aus Rentabilitätsperspektive problematisch — unabhängig davon, wie gut er in anderen Kriterien abschneidet.
### 7. Entwicklungspotenzial des Umfelds
Ist die Gegend im Wachstum? Neue Wohn- oder Gewerbeentwicklungen in unmittelbarer Nähe können die Einzugsgebietsbasis in den ersten Betriebsjahren erheblich vergrößern. Wer einen Standort erschließt, bevor die umliegende Entwicklung abgeschlossen ist, sichert sich häufig günstigere Mietkonditionen und einen Erstmover-Vorteil.
Relevante Informationsquellen: kommunale Bebauungspläne (über die jeweiligen Stadtplanungsämter abrufbar), Flächennutzungspläne, Berichte der lokalen Wirtschaftsförderung. Auch die Auswertung von Baugenehmigungsstatistiken und Bevölkerungsprognosen des Statistischen Bundesamts gibt Hinweise auf mittelfristige Entwicklungskorridore.
### 8. Regulatorisches Umfeld
Die Baugenehmigung ist einer der häufigsten unterschätzten Risikofaktoren im Padelhallen-Projekt. Sechs bis neun Monate Bearbeitungszeit sind keine Ausnahme — und jeder Monat Verzögerung kostet Mietkosten ohne Umsatzgegenwert.
Relevante Prüfpunkte:
- **Nutzungsklasse (Baunutzungsverordnung)**: Ist die geplante Sportnutzung am Standort zulässig? Gewerbliche Sporteinrichtungen sind nicht in allen Nutzungszonen erlaubt.
- **Lärmschutzauflagen**: Besonders bei Außencourts. Die TA Lärm schreibt für Sportanlagen je nach Gebietskategorie strikte Richtwerte vor. Verstöße können den Betrieb von Außencourts dauerhaft einschränken.
- **Kommunale Förderung**: Manche Kommunen unterstützen Sportinfrastruktur aktiv — durch beschleunigte Genehmigungsverfahren, vergünstigte Gewerbeflächen oder sogar direkte Fördermittel. Es lohnt sich, die Wirtschaftsförderung der Zielkommune frühzeitig zu kontaktieren.
---
## Die Standortformel: Aus 8 Kriterien wird eine Entscheidung
Wer mehrere Standorte parallel prüft, braucht ein Vergleichsinstrument. Empfehlenswert ist eine gewichtete Kriterienbewertung: Jedes der acht Kriterien wird auf einer Skala von 1 bis 5 bewertet und mit einem Gewichtungsfaktor multipliziert.
Mögliche Gewichtung nach Bedeutung:
| Kriterium | Gewicht |
|---|---|
| Einzugsgebiet (Bevölkerung, Einkommen, Alter) | 25% |
| Wettbewerbssituation | 20% |
| Miet-Umsatz-Verhältnis | 20% |
| Objekteignung (baulich) | 15% |
| Erreichbarkeit / Parkplätze | 10% |
| Regulatorisches Umfeld | 5% |
| Sichtbarkeit | 3% |
| Entwicklungspotenzial | 2% |
Das Ergebnis ist ein Gesamtscore pro Standort, der einen strukturierten Vergleich ermöglicht. Wichtig: Ein Standort, der in einem der K.O.-Kriterien (Mietverhältnis, Objekteignung) unter dem Minimum liegt, scheidet unabhängig vom Gesamtscore aus.
---
## Häufige Fehler bei der Standortentscheidung
**Der Sichtbarkeits-Irrtum**: Ein teures Objekt in Premiumlage mit hoher Passantenfrequenz klingt überzeugend. Aber: Padel-Kunden buchen online. Sichtbarkeit hilft beim passiven Bekanntheitsaufbau — sie ersetzt kein funktionierendes digitales Marketing, kostet aber unter Umständen 34 Euro pro Quadratmeter mehr Miete. Das summiert sich über fünf Jahre auf einen erheblichen Betrag. Die Frage ist immer: Was kostet die Sichtbarkeit, und was würde ein gleichwertiges Marketingbudget leisten?
**Parkplatzsituation ignoriert**: Wer Parkplätze unterschätzt, bemerkt das Problem erst im Vollbetrieb — und dann ist es strukturell nicht lösbar. Besonders in urbanen Lagen mit Parkraumbewirtschaftung ist die Verfügbarkeit von kostenlosem Kundenparkplatz ein echter Differenzierungsfaktor.
**Regulatorisches Risiko unterschätzt**: Baugenehmigungen scheitern oder verzögern sich aus Gründen, die im Vorfeld erkennbar gewesen wären: falsche Nutzungsklasse, Lärmschutzprobleme bei Außencourts, denkmalgeschützte Nachbarbebauung. Eine Voranfrage beim Bauordnungsamt (formlos, oft kostenlos) vor der Mietentscheidung kann Monate Arbeit und erhebliche Kosten sparen.
**Zu früh auf ein Objekt fixiert**: Die beste Standortentscheidung entsteht aus dem Vergleich von mindestens drei bis fünf Optionen. Wer das erste passende Objekt nimmt, verzichtet auf die Möglichkeit, das Verhältnis von Preis, Lage und Eignung zu optimieren.
---
## Marktreife richtig einschätzen: In welcher Phase ist Ihre Zielstadt?
Die acht Kriterien oben bewerten konkrete Objekte. Bevor Sie aber mit der Objektsuche beginnen, lohnt ein Schritt zurück: In welcher Entwicklungsphase befindet sich der Markt in Ihrer Zielstadt? Die Antwort bestimmt, welche Betreiberstrategie überhaupt Aussicht auf Erfolg hat.
<div class="article-cards">
<div class="article-card article-card--established">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Etablierte Märkte</span>
<p class="article-card__body">Buchungsplattformen zeigen durchgehende Vollauslastung zu Stoßzeiten, Wartelisten sind verbreitet. Die Herausforderung liegt im Wettbewerb: Etablierte Betreiber haben Markenloyalität aufgebaut, günstige Flächen sind vergeben. Neueintretende Betreiber brauchen echten Differenzierungsansatz. Eintrittsinvestment ist hoch — das Ertragspotenzial bei konsequenter Umsetzung ebenfalls. München ist das paradigmatische Beispiel.</p>
</div>
</div>
<div class="article-card article-card--growth">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Wachstumsmärkte</span>
<p class="article-card__body">Die Nachfrage wächst sichtbar — Buchungszeiten füllen sich, neue Anlagen werden eröffnet. Das Angebot hat die Nachfrage noch nicht eingeholt; Versorgungslücken sind erkennbar. Das Fenster für attraktive Flächen zu vertretbaren Konditionen schließt sich. Wer wartet, zahlt den Aufpreis des offensichtlich attraktiven Markts.</p>
</div>
</div>
<div class="article-card article-card--emerging">
<div class="article-card__accent"></div>
<div class="article-card__inner">
<span class="article-card__title">Frühmärkte</span>
<p class="article-card__body">Geringes Angebot, kleine aber wachsende Spielerbasis. Mietkosten niedriger, Standortauswahl größer — aber Nachfrage muss aktiv aufgebaut werden. Anfängerkurse, Vereinskooperationen, lokale Ligen und Konversion von Tennisclubs sind die zentralen Instrumente. Der Weg zur Profitabilität ist länger; die aufgebaute Wettbewerbsposition erweist sich oft als dauerhaft.</p>
</div>
</div>
</div>
Bevor Sie in einer Stadt konkret nach Objekten suchen, sollten Sie deren Marktreife einordnen. Der Kriterienkatalog zeigt, ob ein bestimmtes Objekt geeignet ist; die Marktreife zeigt, welches Betreiberprofil und welche Strategie überhaupt die Voraussetzung für Erfolg ist.
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
[→ Marktüberblick nach Land](/de/markets/germany)
---
## Wie Padelnomics hilft
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
[→ Standortanalyse starten](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -0,0 +1,67 @@
---
title: "Padel-Zubehör: Das braucht jeder Spieler wirklich"
slug: padel-zubehoer-de
language: de
url_path: /padel-zubehoer
meta_description: "Welches Padel-Zubehör lohnt sich wirklich? Von Griffband und Vibrationsdämpfer bis zur Sporttasche — was ist nützlich, was ist Marketing?"
---
# Padel-Zubehör: Das braucht jeder Spieler wirklich
<!-- TODO: Einleitung — Zubehör gibt es viel, sinnvoll ist wenig -->
Wer Padel ernsthafter betreibt, wird früh von Empfehlungen überhäuft: Griffband kaufen! Schutzhülle! Vibrationsdämpfer! Nicht alles davon ist sinnvoll — aber einiges tatsächlich unverzichtbar. Dieser Guide hilft dabei, nützliches Zubehör von überteuertem Marketing zu trennen.
---
## Das sinnvollste Zubehör im Überblick
[product-group:accessory]
---
## Griffband: Ja, unbedingt
<!-- TODO: Erklärung, welches Griffband sich lohnt -->
[product:platzhalter-griffband-amazon]
---
## Schläger-Schutzhülle: Ja, wenn man häufig transportiert
<!-- TODO -->
---
## Vibrationsdämpfer: Geschmackssache
<!-- TODO -->
---
## Sporttasche: Erst ab regelmäßigem Spiel
<!-- TODO -->
---
## Häufige Fragen
<details>
<summary>Wie oft sollte man das Griffband wechseln?</summary>
<!-- TODO -->
Bei regelmäßigem Spielen empfehlen wir einen Wechsel alle 48 Wochen. Ein abgenutztes Griffband erhöht das Risiko, den Schläger wegzuschleudern, und mindert die Kontrolle.
</details>
<details>
<summary>Brauche ich eine spezielle Padeltasche?</summary>
<!-- TODO -->
Eine Padeltasche schützt den Schläger vor Beschädigungen beim Transport. Für gelegentliche Spieler reicht ein einfaches Cover. Wer mehrere Schläger trägt oder regelmäßig zum Club fährt, profitiert von einer Sporttasche mit gepolstertem Schlägerfach.
</details>

View File

@@ -0,0 +1,70 @@
---
title: "Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle"
slug: padelbaelle-vergleich-de
language: de
url_path: /padelbaelle-vergleich
meta_description: "Welche Padelbälle sind am besten? Wir vergleichen die beliebtesten Modelle nach Druckhaltigkeit, Spielgefühl und Preis-Leistungs-Verhältnis."
---
# Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle
<!-- TODO: Einleitung — warum Bälle oft unterschätzt werden -->
Der Ball ist das am häufigsten unterschätzte Equipment im Padel. Dabei entscheidet seine Druckhaltigkeit maßgeblich über das Spielgefühl. Ein Padelball verliert nach 46 Stunden intensivem Spiel merklich an Druck — und damit an Tempo, Kontrolle und Spaß.
---
## Unsere Empfehlungen
[product-group:ball]
---
## Druckhaltigkeit: Was wirklich zählt
<!-- TODO: Erklärung des Druckverlusts + Testzeitraum -->
---
## Turnier- vs. Freizeitball
<!-- TODO -->
---
## Testsieger im Überblick
[product:platzhalter-ball-amazon]
<!-- TODO -->
---
## Häufige Fragen
<details>
<summary>Wie lange hält ein Padelball?</summary>
<!-- TODO -->
Ein hochwertiger Padelball ist nach etwa 48 Stunden Spielzeit merklich weicher. Im Freizeitbereich merkt man den Unterschied oft erst später. Profis und ambitionierte Spieler wechseln Bälle bereits nach einem Set.
</details>
<details>
<summary>Muss ich WCT- oder FIP-zertifizierte Bälle kaufen?</summary>
<!-- TODO -->
Für den Freizeiteinsatz nein. Für Turniere und Ligaspiele ja — die meisten Ligen schreiben zugelassene Ballmodelle vor. Im Training können beliebige Qualitätsbälle verwendet werden.
</details>
<details>
<summary>Wie lagere ich Padelbälle richtig?</summary>
<!-- TODO -->
Kühl und trocken lagern, nicht im Auto lassen. Manche Spieler verwenden Druckbehälter, um den Druckverlust zu verlangsamen — das funktioniert tatsächlich für bereits angebrochene Dosen.
</details>

View File

@@ -0,0 +1,67 @@
---
title: "Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle"
slug: padelschlaeger-anfaenger-de
language: de
url_path: /padelschlaeger-anfaenger
meta_description: "Welcher Padelschläger eignet sich für Anfänger? Unsere Empfehlungen für Einsteiger: verzeihendes Spielgefühl, robuste Verarbeitung, fairer Preis."
---
# Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle
<!-- TODO: Einleitung, warum Anfängerschläger sich von Profimodellen unterscheiden (150200 Wörter) -->
Für den Einstieg ins Padel braucht man keinen teuren Profischaft. Im Gegenteil: Die meisten Hochleistungsschläger sind für Anfänger kontraproduktiv — ihr kleines Sweetspot-Fenster bestraft Fehlschläge, die in der Lernphase normal sind. Ein guter Anfängerschläger ist leicht, hat eine runde Form und verzeiht ungenaue Treffpunkte.
---
## Unsere Top-5 für Einsteiger
[product-group:racket]
---
## Was macht einen guten Anfängerschläger aus?
<!-- TODO: Erklärung der relevanten Schläger-Eigenschaften (Form, Gewicht, Material) -->
### Schlägerkopfform: Rund schlägt Diamant
<!-- TODO -->
### Gewicht: Leichter ist nicht immer besser
<!-- TODO -->
### Material: EVA vs. Foam
<!-- TODO -->
---
## Unsere Empfehlung im Detail
[product:platzhalter-anfaenger-schlaeger-amazon]
<!-- TODO: Ausführliche Besprechung mit Praxistest -->
---
## Häufige Fragen
<details>
<summary>Ab welchem Preis lohnt sich ein eigener Schläger?</summary>
<!-- TODO -->
Wer mehr als einmal pro Woche spielt, sollte in einen eigenen Schläger investieren. Leihschläger im Club sind oft abgenutzt und vermitteln ein falsches Spielgefühl. Ab 6080 Euro gibt es solide Einsteigerschläger.
</details>
<details>
<summary>Kann ich als Anfänger direkt mit einem 150-Euro-Schläger starten?</summary>
<!-- TODO -->
Ja, sofern es sich um ein anfängerfreundliches Modell aus diesem Preisbereich handelt. Preisschilder allein sagen wenig — ein 150-Euro-Diamantschläger kann für Einsteiger schlechter sein als ein 70-Euro-Rundschläger.
</details>

View File

@@ -0,0 +1,55 @@
---
title: "Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026"
slug: padelschlaeger-defensiv-de
language: de
url_path: /padelschlaeger-defensiv
meta_description: "Die besten Padelschläger für defensive und kontrollbetonte Spieler. Runde und Tropfenform mit großem Sweetspot für sicheres Spiel vom Grundfeld."
---
# Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026
<!-- TODO: Einleitung zur defensiven Spielweise und warum der Schläger einen Unterschied macht -->
Im Padel entscheidet das Grundfeld. Wer vom hinteren Drittel sauber und kontrolliert spielen kann, zwingt den Gegner zu Fehlern. Für diesen Spielstil braucht man einen Schläger mit großem Sweetspot, weichem EVA-Kern und einer runden oder Tropfenform — nicht die auffälligsten Geräte, aber die effektivsten.
---
## Unsere Empfehlungen für defensive Spieler
[product-group:racket]
---
## Warum Kontrolle wichtiger ist als Power
<!-- TODO: Erklärung Spielstil + Schlägercharakteristik -->
---
## Testsieger im Detail
[product:platzhalter-defensiv-schlaeger-amazon]
<!-- TODO -->
---
## Häufige Fragen
<details>
<summary>Was ist der Unterschied zwischen einem Kontroll- und einem Powerschläger?</summary>
<!-- TODO -->
Kontrollschläger (runde Form, weicher Kern) vergrößern den Sweetspot und ermöglichen feingefühliges Spiel. Powerschläger (Diamantform, harter Kern) bieten mehr Hebelwirkung beim Smash, verzeihen aber weniger Fehlschläge.
</details>
<details>
<summary>Für welche Spielstufe sind Kontrollschläger geeignet?</summary>
<!-- TODO -->
Kontrollschläger sind für Anfänger, Freizeitspieler und taktisch orientierte Spieler aller Stufen geeignet. Auch viele erfahrene Spieler bevorzugen sie, weil Konsistenz auf Dauer mehr Punkte bringt als gelegentliche Powerschläge.
</details>

View File

@@ -0,0 +1,67 @@
---
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
slug: padelschlaeger-fortgeschrittene-de
language: de
url_path: /padelschlaeger-fortgeschrittene
meta_description: "Die besten Padelschläger für fortgeschrittene und ambitionierte Spieler. High-End-Modelle mit Carbon, Kevlar und ausgereifter Schlagbalance für Spieler ab 3.0."
---
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
Ab einem gewissen Spielniveau lohnt sich der Griff zu einem anspruchsvolleren Schläger. Wer sauber trifft, kann von einer härteren Bespannung und einer präziseren Balance profitieren. Die Schläger in dieser Liste sind kein Selbstläufer — aber in den richtigen Händen ein echter Vorteil.
---
## Top-Schläger für Fortgeschrittene im Überblick
[product-group:racket]
---
## Carbon, Kevlar, Glasfaser: Was steckt drin?
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
### Carbon-Rahmen
<!-- TODO -->
### 3K vs. 12K Carbon
<!-- TODO -->
### Kevlar-Einlagen
<!-- TODO -->
---
## Testbericht: Unser Empfehlungsschläger
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
<!-- TODO: Praxistest -->
---
## Häufige Fragen
<details>
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
<!-- TODO -->
Wer regelmäßig spielt (23 Mal pro Woche), seit mindestens einem Jahr dabei ist und an Taktik und Technik arbeitet, kann von einem hochwertigeren Schläger profitieren. Für gelegentliche Spieler ist der Unterschied zu einem Mittelklassemodell kaum spürbar.
</details>
<details>
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
<!-- TODO -->
Nicht zwingend. Es gibt ausgezeichnete Modelle im 150200-Euro-Segment, die professionell verarbeitete Carbon-Elemente enthalten. Alles über 300 Euro richtet sich meist an Spieler mit Wettkampfambitionen.
</details>

View File

@@ -0,0 +1,55 @@
---
title: "Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026"
slug: padelschlaeger-unter-100-de
language: de
url_path: /padelschlaeger-unter-100
meta_description: "Gute Padelschläger müssen nicht teuer sein. Die besten Modelle unter 100 Euro — mit echtem Spielgefühl, ohne Kompromisse bei der Verarbeitung."
---
# Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026
<!-- TODO: Einleitung — Gibt es wirklich gute Schläger für unter 100 Euro? -->
Wer sagt, dass Padel teuer sein muss? In der 50-100-Euro-Klasse gibt es Schläger, die sich von 200-Euro-Modellen im Freizeitspiel kaum unterscheiden. Der entscheidende Unterschied liegt oft im Material des Rahmens und im Kern — nicht im Spielgefühl.
---
## Die besten Schläger unter 100 Euro
[product-group:racket]
---
## Was bekommt man unter 100 Euro?
<!-- TODO: Realistische Erwartungen setzen -->
---
## Unser Preisklassen-Tipp
[product:platzhalter-budget-schlaeger-amazon]
<!-- TODO -->
---
## Häufige Fragen
<details>
<summary>Sind günstige Padelschläger schlechter verarbeitet?</summary>
<!-- TODO -->
Nicht zwangsläufig. Im Bereich 60100 Euro findet man solide Fiberglas-Schläger bekannter Marken. Der Hauptunterschied zu teureren Modellen ist das Rahmenmaterial (kein Carbon) und ein schlichtes Design.
</details>
<details>
<summary>Lohnt es sich, für einen Einsteiger 100 Euro auszugeben?</summary>
<!-- TODO -->
Ja, wenn er weiß, dass er das Spiel ernsthafter betreiben will. Für einen ersten Test reicht auch ein 50-Euro-Schläger — aber wer nach der ersten Saison weiterspielen will, wird früh aufwerten wollen.
</details>

View File

@@ -0,0 +1,61 @@
---
title: "Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras"
slug: padelschuhe-test-de
language: de
url_path: /padelschuhe-test
meta_description: "Welche Padelschuhe sind am besten? Unser Test der beliebtesten Modelle — für Sand, Kunstgras und Kunststoffbelag mit optimaler Dämpfung und Stabilität."
---
# Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras
<!-- TODO: Einleitung — warum normale Tennisschuhe nicht reichen -->
Padelschuhe werden häufig unterschätzt. Auf dem Sandbelag des Padel-Courts braucht man eine völlig andere Sohle als auf Tennishartplatz oder Hallenboden. Ein falscher Schuh erhöht nicht nur das Verletzungsrisiko — er kostet auch Punkte, weil man in Kurven wegrutscht.
---
## Unsere Top-Empfehlungen
[product-group:shoe]
---
## Welche Sohle für welchen Belag?
<!-- TODO: Sohlentypen und Untergrundtabelle -->
| Belag | Empfohlene Sohle |
|---|---|
| Sand (feiner Quarzsand) | Fishbone / Fischgrät |
| Kunstgras | Multicourt / Omnidirectional |
| Kunststoff/Beton | Glatte Multicourt-Sohle |
---
## Testbericht: Bester Allround-Schuh
[product:platzhalter-padelschuh-amazon]
<!-- TODO -->
---
## Häufige Fragen
<details>
<summary>Kann ich Tennisschuhe für Padel verwenden?</summary>
<!-- TODO -->
Für den gelegentlichen Einstieg ja. Auf Dauer ist es nicht empfehlenswert: Tennisschuhe bieten auf Sand zu wenig Halt, und die Abnutzung ist höher. Nach 34 Monaten regelmäßigen Spielens zahlen sich dedizierte Padelschuhe aus.
</details>
<details>
<summary>Wie erkenne ich verschlissene Padelschuhe?</summary>
<!-- TODO -->
Wenn die Außenfläche der Sohle glatt wird oder das Profil auf unter 2 mm abgenutzt ist, verliert der Schuh seinen Halt. Bei Padel ist das gefährlicher als bei vielen anderen Sportarten, weil häufige Richtungswechsel auf losem Sand stattfinden.
</details>

View File

@@ -160,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
[→ Businessplan erstellen]
[→ Businessplan erstellen](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -160,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
[→ Generate your business plan]
[→ Generate your business plan](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -331,8 +331,10 @@ Building a padel hall is complex, but it is a solved problem. The failures are n
---
## Find Builders and Suppliers Through Padelnomics
## Find the Right Build Partners
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants.
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -191,4 +191,10 @@ Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
**Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -179,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
[scenario:padel-halle-6-courts:full]
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -218,6 +218,12 @@ The investors who succeed long-term in padel aren't the ones who found a risk-fr
## Model the Downside with Padelnomics
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
Good decisions need an honest model — not just the best-case assumptions.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -176,7 +176,7 @@ Before committing to a site search in any city, calibrate where it sits on this
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
[→ View market data by country](/markets/germany)
[→ View market data by country](/en/markets/germany)
---
@@ -184,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
[→ Run a location analysis]
[→ Run a location analysis](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -326,8 +326,10 @@ Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehl
---
## Planer und Lieferanten finden
## Die richtigen Baupartner finden
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater.
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -199,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
[scenario:padel-halle-6-courts:full]
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -189,4 +189,10 @@ Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
**Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -216,6 +216,12 @@ Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfo
## Die Padelnomics-Investitionsrechnung
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -166,7 +166,7 @@ Bevor Sie in einer Stadt konkret nach Objekten suchen, sollten Sie deren Marktre
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
[→ Marktüberblick nach Land](/markets/germany)
[→ Marktüberblick nach Land](/de/markets/germany)
---
@@ -174,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
[→ Standortanalyse starten]
[→ Standortanalyse starten](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -1,6 +1,6 @@
# Padelnomics — Marketing Master Doc
> Living doc. Update state column as things progress. Last updated: 2026-02-22.
> Living doc. Update state column as things progress. Last updated: 2026-03-04.
---
@@ -216,9 +216,9 @@ The moat compounds over time — this is critical to long-term defensibility.
| Channel | Approach | State |
|---------|----------|-------|
| **LinkedIn** | Founder posts, thought leadership, padel community | [ ] Not started |
| **Reddit** | r/padel, r/entrepreneur — seeding calculator, articles | [ ] Not started |
| **Facebook Groups** | Padel business groups, sports entrepreneur communities | [ ] Not started |
| **LinkedIn** | Founder posts, thought leadership, padel community | [~] First post published |
| **Reddit** | r/padel, r/sweatystartup, r/entrepreneur, r/tennis, r/smallbusiness, r/pickleball, r/CRE — seeding calculator, articles | [~] Active in 7 subreddits |
| **Facebook Groups** | Padel business groups, sports entrepreneur communities | [~] Active in 2-3 groups |
### Borrowed (Month 2+)

89
docs/gtm-day-one.md Normal file
View File

@@ -0,0 +1,89 @@
# GTM — Day One Action Plan
> Created: 2026-03-04. Do these in order. Total time: ~45 hours.
---
## Right Now (12 hours, highest leverage)
### 1. Submit sitemap to Google Search Console + Bing Webmaster Tools
You have 80 programmatic city articles sitting unindexed. Every day without indexing is wasted compound time.
- [search.google.com/search-console](https://search.google.com/search-console) → Add property → Submit sitemap
- [bing.com/webmasters](https://www.bing.com/webmasters) (Bing also feeds DuckDuckGo, Ecosia, Yahoo)
- Your SEO hub already supports both — just add the env vars
### 2. Publish SEO articles on prod
Run `seed_content --generate` from admin or CLI. Those 80 city pages (40 cities × EN+DE) are the primary organic traffic engine. Until they're live and crawlable, they generate zero value.
### 3. Index the planner in Google
Make sure `/en/calculator` and `/de/rechner` are in the sitemap and crawlable. This is the #1 free tool — the entire PLG funnel starts here. Check canonical tags and hreflang are correct.
---
## This Afternoon (23 hours, seed distribution)
### 4. First LinkedIn post
Data-driven insight from the pipeline. See `docs/social-posts.md` for the full post.
### 5. Post in Reddit communities
- **r/padel**: Free calculator angle — genuinely useful tool
- **r/entrepreneur**: Indie maker angle — "built this with real market data"
- **r/smallbusiness**: Business planning tool angle
- **r/tennis**: Cross-sport angle — tennis clubs adding padel courts
See `docs/social-posts.md` for all posts ready to copy-paste.
### 6. Share in 23 Facebook padel business groups
Same angle as Reddit — free tool, no hard sell. Search for:
- "Padel Business" groups
- "Padel Club Owners" groups
- "Padel Deutschland" / "Padel Germany" groups
---
## This Evening (1 hour, set up compounding assets)
### 7. Verify Resend production API key
Test a real magic link email. Until email works in prod, you can't capture traffic.
### 8. Wipe test suppliers
Delete the 5 `example.com` entries. Empty directory with "Be the first to list" > obviously fake data.
### 9. Request indexing for top 5 city pages
After GSC is set up, use "Request Indexing" manually for highest-value pages:
- `/de/markets/berlin`, `/de/markets/muenchen`, `/de/markets/hamburg`
- `/en/markets/london`, `/en/markets/madrid`
Google prioritizes manually requested URLs — can appear in search within days vs. weeks.
---
## What NOT to do today
- ~~"State of Padel" report~~ — multi-day effort
- ~~Supplier outreach~~ — site needs to be live + articles indexed first
- ~~Copy/CRO optimization~~ — premature, get traffic first
- ~~Paid ads~~ — excluded in channel strategy
---
## Expected outcome
If you do steps 19 today:
- 80 pages submitted for indexing (organic traffic starts in 13 weeks)
- 35 social posts seeding traffic immediately
- Planner discoverable and shareable
- Email capture working for when traffic arrives
**Single highest-leverage action: publish the articles + submit the sitemap.** Everything else is distribution on top of that foundation.

View File

@@ -0,0 +1,91 @@
# Reddit Communities — Padelnomics Distribution
> Permanent reference for Reddit distribution. Subreddits ranked by relevance + size.
> Created: 2026-03-04. Review monthly — subreddit rules change.
---
## Tier 1 — Post Here First
High relevance, receptive to tools/data, proven padel or business-planning interest.
| Subreddit | Size | Angle | Notes |
|-----------|------|-------|-------|
| r/padel | ~20K | Free calculator, data insights, answer existing biz threads | Player community — lead with the sport, not the product. Helpful tone only. |
| r/sweatystartup | ~56-81K | "Best brick-and-mortar sports opportunity" with unit economics | Loves concrete P&L numbers. Show CAPEX/OPEX/payback, not vision. |
| r/tennis | ~2M | Tennis club court conversion trends + data | Huge audience. Angle: "your club is probably already thinking about this." |
| r/smallbusiness | ~2.2M | Free business planning tool for sports facilities | Practical, no-hype tone. Lead with the tool, not the market thesis. |
---
## Tier 2 — Test With One Post Each
Potentially high-value but less proven fit. Post once, measure engagement, double down if it works.
| Subreddit | Size | Angle | Notes |
|-----------|------|-------|-------|
| r/entrepreneur | ~4.8M | "Bloomberg for padel" indie builder story | Loves "I built X" posts with real data. Show the data pipeline, not just the product. |
| r/CommercialRealEstate | ~44K | Sports venue site selection as niche CRE | Small but highly targeted. Angle: alternative asset class with data backing. |
| r/realestateinvesting | ~1.2M | Alternative commercial RE asset class | Broader audience. Frame padel as "the new self-storage" — boring but profitable. |
| r/pickleball | ~30K | Padel vs pickleball facility economics comparison | Comparative angle works. Don't trash pickleball — frame as "here's what the padel side looks like." |
| r/gymowners | Small | Cross-reference gym location frameworks with padel data | Niche. Test if gym owners see padel as a complementary or competing asset. |
| r/padelUSA | <5K | US-specific demand data | Tiny but highly relevant. US padel market is nascent — early authority opportunity. |
---
## Tier 3 — Monitor Only
Read these for trends and conversations. Don't post unless a specific thread is a perfect fit for a data-backed comment.
- r/business — too generic, self-promo gets buried
- r/startups — SaaS-focused, padel doesn't fit the narrative
- r/SaaS — pure software community, facility business is off-topic
- r/venturecapital — wrong audience for bootstrapped niche tool
- r/sports — massive, low engagement on niche content
---
## Key Gap
No subreddit exists for padel facility operators or business owners. If community forms organically around Padelnomics content (comments like "where can I discuss this more?"), consider creating **r/padelbusiness** later. Don't force it — let demand signal the timing.
---
## Posting Rules
1. **One link per post, at the end.** Never in the title.
2. **Engage with every comment for 24 hours** after posting. This is where the real value is.
3. **No cross-posting.** Each post is unique to the subreddit's culture and tone.
4. **If a post gets removed, don't repost.** Move to the next subreddit. Respect mod decisions.
5. **Read each subreddit's rules before posting.** Some ban self-promotion entirely. Some require flair. Some have minimum account age/karma requirements.
6. **Never post more than one subreddit per day.** Spread it out. Reddit's spam detection flags rapid multi-sub posting.
7. **Comment on existing threads first.** Build karma and presence in a sub before dropping your own post.
---
## UTM Tracking Format
All Reddit links use this format:
```
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_<subreddit>
```
Examples:
- `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup`
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre`
---
## Measuring Success
| Metric | Good | Great |
|--------|------|-------|
| Post upvotes | 10+ | 50+ |
| Comments | 5+ | 20+ |
| UTM clicks (GA) | 20+ per post | 100+ per post |
| Planner completions from Reddit | 5+ per post | 20+ per post |
| Email captures from Reddit | 2+ per post | 10+ per post |
Track weekly in a simple spreadsheet. Drop subreddits that produce zero clicks after 2 posts.

106
docs/reddit-posting-plan.md Normal file
View File

@@ -0,0 +1,106 @@
# Reddit Posting Plan — Launch Sequence
> Day-by-day posting schedule. One post per day, engage for 24 hours after each.
> Created: 2026-03-04. See `docs/reddit-communities.md` for full subreddit research.
---
## Posting Sequence
| Day | Subreddit | Post Title | Angle | UTM |
|-----|-----------|-----------|-------|-----|
| 1 | r/padel | "I built a free padel court ROI calculator — feedback welcome" | Free tool, genuinely helpful | `utm_content=r_padel` |
| 2 | r/sweatystartup | "25K venues analyzed — which cities are undersupplied for padel" | Unit economics, brick-and-mortar opportunity | `utm_content=r_sweatystartup` |
| 3 | r/entrepreneur | "I'm building the 'Bloomberg for padel' — tracking 10,127 facilities across 17 countries" | Indie builder story with real data | `utm_content=r_entrepreneur` |
| 4 | r/tennis | "Data on padel facility economics — useful for tennis clubs considering adding courts" | Tennis club conversion data | `utm_content=r_tennis` |
| 5 | r/smallbusiness | "Free business planning tool for anyone looking at opening a sports facility" | Practical tool for real decisions | `utm_content=r_smallbusiness` |
| 7 | r/pickleball | "Padel vs pickleball facility economics — a data comparison" | Comparative, respectful of pickleball | `utm_content=r_pickleball` |
| 10 | r/CommercialRealEstate | "Sports venue site selection — data on underserved markets" | Alternative CRE asset class | `utm_content=r_cre` |
Day 6 and days 8-9 are rest days for engaging with comments on previous posts.
---
## Full UTM Format
Every Reddit link follows this exact format:
```
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=<value>
```
| Subreddit | utm_content value |
|-----------|-------------------|
| r/padel | `r_padel` |
| r/sweatystartup | `r_sweatystartup` |
| r/entrepreneur | `r_entrepreneur` |
| r/tennis | `r_tennis` |
| r/smallbusiness | `r_smallbusiness` |
| r/pickleball | `r_pickleball` |
| r/CommercialRealEstate | `r_cre` |
---
## Post Content
Full post text is in `docs/social-posts.md`. Before posting, replace `[LINK]` placeholders with the correct UTM-tagged URL:
| Post | Link to |
|------|---------|
| r/padel | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel` |
| r/sweatystartup | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup` |
| r/entrepreneur | `https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur` |
| r/tennis | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis` |
| r/smallbusiness | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness` |
| r/pickleball | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_pickleball` |
| r/CommercialRealEstate | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre` |
---
## Rules
1. **One link per post, at the end.** Never in the title.
2. **Engage with every comment for 24 hours** after posting.
3. **No cross-posting.** Each post is written uniquely for its subreddit's culture.
4. **If a post gets removed, don't repost.** Move to the next subreddit.
5. **Read subreddit rules before posting.** Check for self-promotion policies, flair requirements, minimum karma.
6. **Comment on 2-3 existing threads** in a subreddit before making your own post (builds credibility).
7. **Never mention other posts.** Each community should feel like they're getting a unique share.
---
## Engagement Playbook
### When you get comments:
- **"How accurate is this?"** — Share methodology: real market data from OpenStreetMap, Playtomic, Eurostat. Not generic assumptions.
- **"What about [city]?"** — Run the planner for their city, share the numbers. This is high-value personalized engagement.
- **"I'm actually looking at opening a facility"** — Offer to walk through the planner with them. Ask about their timeline, location, budget. This is a lead.
- **"This is just an ad"** — Don't get defensive. Say "Fair point — I built this and wanted feedback. The tool is free with no signup, so figured it might be useful here."
- **"What's your business model?"** — Be transparent: "Free calculator, paid market intelligence for serious investors, supplier directory for builders."
### When a post gets traction (50+ upvotes):
- Reply with additional data points to keep the thread alive
- Answer every question, even late ones
- Don't edit the original post to add more links
---
## Tracking
After each post, log:
| Field | Example |
|-------|---------|
| Date posted | 2026-03-04 |
| Subreddit | r/padel |
| Post URL | reddit.com/r/padel/... |
| Upvotes (24hr) | 15 |
| Comments (24hr) | 7 |
| UTM clicks (GA, 7d) | 42 |
| Planner starts (7d) | 12 |
| Emails captured (7d) | 3 |
| Removed? | No |
Review after Day 10. Double down on subreddits that drove clicks. Drop ones that didn't.

View File

@@ -0,0 +1,150 @@
# SEO Content Calendar — First 30 Days
> 4-week content plan covering programmatic SEO deployment, cornerstone articles, and data-driven content.
> Created: 2026-03-04.
---
## Week 1 — Foundation (March 4-10)
Get the existing 80 pages indexed and write the first cornerstone article.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon | Publish 80 programmatic city articles (40 cities x EN+DE) | Deploy | [ ] |
| Mon | Submit sitemap to Google Search Console | Manual | [ ] |
| Mon | Submit sitemap to Bing Webmaster Tools | Manual | [ ] |
| Tue | Request manual indexing for top 10 pages in GSC | Manual | [ ] |
| Tue | Verify hreflang tags and canonical URLs on all city pages | Audit | [ ] |
| Wed-Fri | Write Article #1: "Is Padel Still a Good Investment in 2026?" | Editorial | [ ] |
| Fri | Publish Article #1, add to sitemap | Deploy | [ ] |
**Top 10 pages for manual indexing:**
1. `/de/markets/berlin`
2. `/de/markets/muenchen`
3. `/de/markets/hamburg`
4. `/en/markets/london`
5. `/en/markets/madrid`
6. `/en/calculator`
7. `/de/rechner`
8. `/en/markets/paris`
9. `/de/markets/frankfurt`
10. `/de/markets/koeln`
---
## Week 2 — Cornerstone Content (March 11-17)
Two high-value articles targeting decision-stage keywords. Internal linking pass connects everything.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Tue | Write Article #2: "How Much Does It Cost to Open a Padel Hall in Germany?" | Editorial | [ ] |
| Wed | Publish Article #2 | Deploy | [ ] |
| Thu-Fri | Write Article #3: "What Banks Want to See in a Padel Business Plan" | Editorial | [ ] |
| Fri | Publish Article #3 | Deploy | [ ] |
| Sat | Internal linking pass: city articles -> cornerstone articles -> planner | Technical | [ ] |
### Article #2 — Target Keywords
- "padel halle kosten" / "padel court cost germany"
- "padel halle eroeffnen kosten" / "how much to open padel hall"
- "padel anlage investition"
### Article #3 — Target Keywords
- "padel business plan" / "padel halle business plan"
- "padel halle finanzierung" / "padel financing"
- "bank business plan padel"
### Internal Linking Structure
```
City article (e.g., /markets/berlin)
-> "How much does it cost?" (Article #2)
-> "Plan your facility" (/calculator)
Article #2 (Cost breakdown)
-> "Build your business plan" (/calculator)
-> "What banks want to see" (Article #3)
-> City-specific examples (/markets/muenchen, /markets/hamburg)
Article #3 (Bank requirements)
-> "Generate your business plan" (/calculator)
-> "Check market data for your city" (/markets)
```
---
## Week 3 — Data-Driven Content (March 18-24)
Leverage the pipeline data for unique content nobody else can produce.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Wed | Write "Top 50 Underserved Locations for Padel in Europe" | Editorial | [ ] |
| Wed | Publish Top 50 article | Deploy | [ ] |
| Thu-Fri | Build Gemeinde-level pSEO template (targets "Padel in [Ort]") | Technical | [ ] |
| Fri | Generate first batch of Gemeinde pages (top 20 locations) | Deploy | [ ] |
### Top 50 Article
- Source data from `location_opportunity_profile` in the serving layer
- Rank by opportunity score, filter to locations with zero existing facilities
- Include mini-profiles: population, income level, nearest existing facility, opportunity score
- Embed interactive map if possible, otherwise static top-50 table
- Target keywords: "where to open padel", "best locations padel europe", "padel market gaps"
### Gemeinde-Level pSEO
- Template targets: "Padel in [Ort]" / "Padel [Gemeinde]"
- Zero SERP competition confirmed for most German municipalities
- Content: local demographics, nearest facilities, opportunity score, CTA to planner
- Start with top 20 highest-opportunity Gemeinden, expand weekly
---
## Week 4 — Authority Building (March 25-31)
Establish Padelnomics as the data authority. Begin email-gated content for list building.
| Day | Task | Owner | State |
|-----|------|-------|-------|
| Mon-Wed | Write "State of Padel Q1 2026" report | Editorial | [ ] |
| Wed | Design PDF layout (WeasyPrint or similar) | Technical | [ ] |
| Thu | Publish report landing page (email-gated download) | Deploy | [ ] |
| Thu | Promote Market Score methodology page via social | Social | [ ] |
| Fri | Begin link building via Reddit/LinkedIn engagement | Social | [ ] |
| Ongoing | Monitor GSC for indexing progress, fix crawl errors | Technical | [ ] |
### State of Padel Q1 2026 Report
- Executive summary of European padel market
- Facility count by country (from pipeline data)
- Growth trends (year-over-year where data exists)
- Top opportunity markets (from opportunity scoring)
- Investment economics summary (from planner defaults)
- Email-gated: free download in exchange for email address
- Promote via LinkedIn, Reddit, and direct outreach to industry contacts
---
## Content Inventory (End of Month 1)
| Type | Count | State |
|------|-------|-------|
| Programmatic city articles (EN+DE) | 80 | Deployed Week 1 |
| Cornerstone articles | 3 | Published Weeks 1-2 |
| Data-driven article (Top 50) | 1 | Published Week 3 |
| Gemeinde-level pSEO pages | 20+ | Started Week 3 |
| Gated report (State of Padel) | 1 | Published Week 4 |
| **Total indexable pages** | **105+** | |
---
## SEO KPIs — End of Month 1
| Metric | Target |
|--------|--------|
| Pages indexed (GSC) | 80+ of 105 |
| Organic impressions | 500+ |
| Organic clicks | 50+ |
| Average position (target keywords) | Top 50 |
| Email captures from gated report | 50+ |
| Backlinks acquired | 3+ |
These are conservative baselines. Programmatic pages in zero-competition niches can index and rank faster than typical content.

153
docs/social-posts-de.md Normal file
View File

@@ -0,0 +1,153 @@
# Social Posts — Deutsche Versionen
> Fertige Posts zum Rauskopieren. Domain: padelnomics.io
> Erstellt: 2026-03-04.
>
> Reddit-Posts bleiben auf Englisch (englischsprachige Subreddits).
> Diese Datei enthält LinkedIn- und Facebook-Posts auf Deutsch.
---
## LinkedIn Post #1 — Marktdaten
> Ziel: Glaubwürdigkeit aufbauen + Traffic auf den Rechner lenken.
```
10.127 Padel-Anlagen in 17 Ländern — wir haben sie alle erfasst.
Was dabei auffällt:
→ Italien führt mit 3.069 Anlagen. Mehr als Spanien (2.241).
→ Portugal hat den reifsten Padel-Markt weltweit (Maturity Score 45,2/100) — bei „nur" 506 Anlagen.
→ Deutschland: 359 Anlagen für 84 Mio. Einwohner. Spanien: 2.241 für 47 Mio.
Diese Lücke ist die Chance.
Wir haben 15.390 Standorte ohne Padel-Angebot identifiziert, die hohes Potenzial zeigen. Hamburg, München und Frankfurt stehen in Deutschland ganz oben.
Für alle, die über eine eigene Padel-Anlage nachdenken oder jemanden beraten: Wir haben einen kostenlosen ROI-Rechner gebaut, der mit echten Marktdaten die Kosten, Umsätze und Amortisation für jede Stadt in Europa modelliert.
Ohne Anmeldung. Einfach rechnen.
→ https://padelnomics.io/de/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten
#padel #sportbusiness #marktdaten #unternehmertum
```
---
## LinkedIn Post #2 — Standortanalyse (Tag 23 posten)
```
Die 5 am stärksten unterversorgten Städte für Padel in Europa:
1. Hamburg — 1,85 Mio. Einwohner, keine einzige Padel-Anlage
2. München — 1,26 Mio. Einwohner, starke Sportkultur, kaum Angebot
3. Bergen (Norwegen) — 294.000 Einwohner, Opportunity Score: 87,5/100
4. Graz (Österreich) — 303.000 Einwohner, null Courts, hohes Einkommen
5. Genf (Schweiz) — 202.000 Einwohner, null Courts, höchste Kaufkraft
Keine Schätzungen. Wir bewerten 143.877 Standorte in Europa anhand von Bevölkerungsdichte, Einkommensdaten, bestehendem Angebot und Sportinfrastruktur.
Der Padel-Markt wächst von 25.000 auf über 50.000 Anlagen weltweit. Die Frage ist nicht ob — sondern wo.
→ Daten für eure Stadt: https://padelnomics.io/de/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_standortanalyse
#padel #marktanalyse #sportsinvestment #immobilien
```
---
## LinkedIn Post #3 — Gründerstory (optional, Woche 2)
```
Vor einem Jahr habe ich angefangen, den europäischen Padel-Markt systematisch zu erfassen.
Der Auslöser: Jeder, der eine Padel-Halle plant, trifft eine Entscheidung im sechsstelligen Bereich — und hat dafür keine belastbaren Daten. Kein zentrales Marktbild. Keine vergleichbaren Kennzahlen. Nur Excel und Bauchgefühl.
Daraus ist Padelnomics entstanden: eine Datenplattform für die Padel-Branche.
Was heute live ist:
→ Kostenloser ROI-Rechner mit stadtspezifischen Realdaten
→ 80 Marktanalysen für Städte in 17 Ländern
→ Standortbewertung für 143.877 Orte in Europa
→ Anbieterverzeichnis für Bau und Ausstattung
Die Daten kommen aus OpenStreetMap, Playtomic, Eurostat und Zensusdaten — automatisch aggregiert und bewertet.
Noch am Anfang, aber der Datenvorsprung wächst jeden Tag.
→ https://padelnomics.io/de/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_gruenderstory
#padel #startup #datenplattform #sportbusiness
```
---
## Facebook — Padel-Gruppen (Deutschland/DACH)
> Ton: locker, hilfsbereit, kurz. Kein Pitch.
**Titel (falls die Gruppe Titel erlaubt):** Kostenloser Padel-Rechner mit echten Marktdaten
```
Moin zusammen,
ich hab einen kostenlosen Finanzplanungs-Rechner für Padel-Anlagen gebaut. CAPEX, laufende Kosten, Umsatzprognose — und am Ende eine 5-Jahres-GuV mit Amortisation.
Der Unterschied zu den üblichen Excel-Vorlagen: Der Rechner befüllt sich automatisch mit echten Daten für euren Standort. Mieten, Nebenkosten, Genehmigungsgebühren — alles stadtspezifisch, basierend auf Daten aus 17 Ländern.
Keine Anmeldung, kostenlos.
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel_de
Feedback ist willkommen — gerade von Leuten, die den Planungsprozess schon hinter sich haben und wissen, welche Zahlen wirklich zählen.
```
---
## Facebook — Tennisvereine / Sportvereine (DACH)
> Ziel: Tennisvereine, die über Padel-Courts nachdenken.
```
Falls euer Verein gerade über Padel-Courts nachdenkt (und viele tun das): Ich hab ein kostenloses Tool gebaut, das die Wirtschaftlichkeit durchrechnet.
→ Investitionskosten für 26 Courts an bestehenden Anlagen
→ Umsatzprognose auf Basis realer Auslastungs- und Preisdaten
→ Laufende Kosten für euren konkreten Standort
→ Amortisation und ROI-Kennzahlen
Ein paar Zahlen aus unseren Daten:
- Durchschnittliche Auslastung in reifen Märkten: 6075 %
- Outdoor-Anlage mit 4 Courts: 200.000350.000 €
- Indoor: 700.0003 Mio. € je nach Bauweise
- Tennisvereine, die 2 Plätze umrüsten, sehen typischerweise nach 1830 Monaten Amortisation
Keine Anmeldung nötig.
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_tennis_de
Kann gern Daten zu einzelnen Städten oder Regionen teilen, wenn ihr etwas Konkretes prüft.
```
---
## Posting-Zeitplan
| Tag | Plattform | Post |
|-----|-----------|------|
| Heute | LinkedIn (Company Page) | Post #1 (Marktdaten) |
| Heute | 12 deutsche FB-Padel-Gruppen | Padel-Rechner |
| Morgen | 12 FB-Tennisvereins-Gruppen | Tennisverein-Angle |
| Tag 3 | LinkedIn (Company Page) | Post #2 (Standortanalyse) |
| Woche 2 | LinkedIn (Company Page) | Post #3 (Gründerstory) |
---
## Regeln
- Ein Link pro Post, am Ende.
- 24 Stunden auf jeden Kommentar reagieren.
- Wenn ein Post Traktion bekommt: mit zusätzlichen Datenpunkten nachliefern.
- UTM-Tracking: `?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten` bzw. `utm_source=facebook` für FB-Posts.

248
docs/social-posts.md Normal file
View File

@@ -0,0 +1,248 @@
# Social Posts — Launch Day
> Ready to copy-paste. Domain: padelnomics.io
> Created: 2026-03-04.
---
## LinkedIn Post #1 — Data Insight
> Post type: data-driven thought leadership. Goal: establish credibility + drive traffic to planner.
```
We've been tracking 10,127 padel facilities across 17 countries.
Here's what surprised me about the European market:
→ Italy leads with 3,069 facilities — more than Spain (2,241)
→ Portugal has the world's most mature padel market (45.2/100 maturity score) with "only" 506 facilities
→ Germany has just 359 facilities for 84M people. Spain has 2,241 for 47M.
That gap is the opportunity.
We identified 15,390 high-potential locations with zero padel courts worldwide.
Hamburg, Munich, and Frankfurt top the list in Germany alone.
If you're thinking about opening a padel facility — or advising someone who is — we built a free ROI calculator that uses this data to model costs, revenue, and payback period for any city in Europe.
No signup required. Just real numbers.
→ https://padelnomics.io/en/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marketdata
#padel #sportsbusiness #marketdata #entrepreneurship
```
---
## LinkedIn Post #2 — Opportunity Angle (schedule for Day 23)
```
The 5 most underserved cities for padel in Europe right now:
1. Hamburg (1.85M residents, zero dedicated padel facilities)
2. Munich (1.26M residents, massive sports culture, minimal supply)
3. Bergen, Norway (294K residents, opportunity score: 87.5/100)
4. Graz, Austria (303K residents, zero courts, high income)
5. Geneva, Switzerland (202K residents, zero courts, highest purchasing power)
These aren't guesses. We score 143,877 locations across Europe using population density, income data, existing supply, and sports infrastructure.
The padel market is growing from 25K to 50K+ facilities globally. The question isn't whether — it's where.
→ Explore the data for your city: https://padelnomics.io/en/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_opportunity
#padel #marketintelligence #sportsinvestment #realestate
```
---
## Reddit — r/padel
> Tone: genuinely helpful, not promotional. r/padel is a player community, so lead with the sport angle.
**Title:** I built a free padel court ROI calculator — feedback welcome
```
Hey r/padel,
I've been working on a data project tracking the padel market across Europe
(facility counts, market maturity, opportunity gaps). As part of that, I built
a free calculator for anyone thinking about opening a padel facility.
It models:
- CAPEX (construction, equipment, permits)
- OPEX (rent, staffing, utilities, maintenance)
- Revenue projections based on real market data from your city
- 5-year P&L with payback period, IRR, and break-even
It pre-fills with city-specific defaults — so if you pick Munich, it uses
Munich rents, Munich utility costs, etc. Not generic averages.
No signup needed. Just wanted to share in case anyone here has ever thought
about the business side of padel.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel
Happy to answer questions about the data or methodology. Also open to feedback
on what would make this more useful.
```
---
## Reddit — r/entrepreneur
> Tone: indie builder sharing a project. r/entrepreneur loves "I built X" posts with real data.
**Title:** I'm building the "Bloomberg for padel" — tracking 10,127 facilities across 17 countries
```
Padel is the fastest-growing sport in Europe and Latin America. There are now
10,000+ facilities worldwide and the market is expected to double to 50K+ in
the next 5 years.
The problem: anyone trying to open a padel facility is flying blind. No
centralized market data exists. People are making €200K€2M investment
decisions based on Excel spreadsheets and gut feel.
I'm building Padelnomics — a data intelligence platform for the padel industry.
Think "Kpler for padel" if you're familiar with commodity data platforms.
What's live right now:
- Free ROI calculator that models costs, revenue, and payback for any European
city (pre-filled with real local data — rents, utilities, permits, etc.)
- 80 market analysis pages covering cities across 17 countries
- Market maturity scoring for 4,686 cities with padel facilities
- Opportunity scoring for 143,877 locations (identifying where to build next)
The data comes from OpenStreetMap, Playtomic (booking platform), Eurostat, and
census data — aggregated and scored automatically.
Revenue model: free calculator captures leads (aspiring facility owners) →
supplier directory connects them with builders → suppliers pay for qualified
leads via credit system.
Still early but the data moat compounds daily — every day of scraping = data
competitors can't replicate.
Would love feedback from anyone who's built data products or two-sided
marketplaces.
→ https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur
```
---
## Reddit — r/smallbusiness
> Tone: practical tool for a real business decision.
**Title:** Free business planning tool for anyone looking at opening a sports facility
```
I built a free financial planning tool specifically for padel facilities
(indoor/outdoor sports courts — fastest growing sport in Europe right now).
It covers the full picture:
- Construction costs (indoor vs outdoor, number of courts)
- Operating expenses (rent, staff, utilities, insurance, maintenance)
- Revenue modeling (hourly rates, occupancy rates, lessons, events)
- 5-year P&L projection
- Key metrics: payback period, IRR, break-even point
The tool pre-fills with real data for your city — actual local rents, utility
costs, permit fees — not generic averages.
You can also generate a bank-ready business plan PDF from it.
Free to use, no signup required for the calculator itself.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness
Built this because I kept seeing people on forums asking "how much does it cost
to open a padel hall?" and getting wildly different answers. Figured real data
was better than guesswork.
```
---
## Reddit — r/tennis
> Tone: cross-sport angle. Many tennis clubs are adding padel courts.
**Title:** Data on padel facility economics — useful for tennis clubs considering adding courts
```
If your club is thinking about adding padel courts (and many are right now),
I built a free financial planning tool that models the full economics:
- CAPEX for adding 26 courts to an existing facility
- Revenue projections based on real occupancy and pricing data
- Operating costs specific to your city/country
- Payback period and ROI metrics
The tool uses actual market data — we track 10,127 padel facilities across
17 countries and score market maturity + opportunity by city.
Some interesting numbers:
- Average padel facility in a mature market runs at 6075% occupancy
- A 4-court outdoor setup costs €200K€350K
- Indoor builds jump to €700K€3M depending on structure
- Tennis clubs converting 2 courts to padel typically see payback in 1830 months
Free to use, no signup needed.
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis
Happy to share data on any specific city or country if you're evaluating this
for your club.
```
---
## Facebook Groups — Padel Business / Deutschland
> Tone: casual, helpful. Shorter than Reddit posts.
**Title (if group allows):** Free padel facility ROI calculator — uses real market data
```
Hey everyone 👋
Built a free tool for anyone planning a padel facility. It models CAPEX,
OPEX, revenue, and gives you a 5-year P&L with payback period.
The difference from spreadsheet templates: it pre-fills with real data for
your city (actual rents, utility costs, permit fees, etc.) based on data
we're collecting across 17 countries.
No signup, no cost. Just real numbers.
→ https://padelnomics.io/en/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel
Feedback welcome — especially from anyone who's been through the planning
process and knows what numbers actually matter.
```
---
## Posting Schedule
| Day | Platform | Post |
|-----|----------|------|
| Today | LinkedIn | Post #1 (Data Insight) |
| Today | r/padel | Calculator feedback post |
| Today | r/entrepreneur | "Bloomberg for padel" builder post |
| Today | 12 FB groups | Calculator share |
| Tomorrow | r/smallbusiness | Business planning tool post |
| Tomorrow | r/tennis | Tennis club angle |
| Day 3 | LinkedIn | Post #2 (Opportunity Angle) |
---
## Rules
- Never link-spam. One link per post, at the end.
- Engage with every comment for 24 hours after posting.
- If a post gets traction, reply with additional data points to keep it alive.
- Track which subreddits/groups drive actual signups via UTM params:
`?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`

View File

@@ -19,7 +19,7 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import get_last_cursor, landing_path, write_gzip_atomic
from .utils import landing_path, skip_if_current, write_gzip_atomic
logger = setup_logging("padelnomics.extract.census_usa")
@@ -73,10 +73,10 @@ def extract(
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
# Skip if we already have data for this month (annual data, monthly cursor)
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
if last_cursor == year_month:
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if skip:
logger.info("already have data for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
return skip
year, month = year_month.split("/")
url = f"{ACS_URL}&key={api_key}"

View File

@@ -26,6 +26,10 @@ EUROSTAT_BASE_URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/
# Dataset configs: filters fix dimension values, geo_dim/time_dim are iterated.
# All other dimensions must either be in filters or have size=1.
#
# Optional `dataset_code` field: when present, used for the API URL instead of the dict key.
# This allows multiple entries to share the same Eurostat dataset with different filters
# (e.g. five prc_ppp_ind entries with different ppp_cat values).
DATASETS: dict[str, dict] = {
"urb_cpop1": {
"filters": {"indic_ur": "DE1001V"}, # Population on 1 January, total
@@ -51,6 +55,59 @@ DATASETS: dict[str, dict] = {
"geo_dim": "geo",
"time_dim": "time",
},
# ── Direct-value datasets (actual EUR figures) ───────────────────────────
"nrg_pc_205": {
# Electricity prices for non-household consumers, EUR/kWh, excl. taxes
"filters": {"freq": "S", "nrg_cons": "MWH500-1999", "currency": "EUR", "tax": "I_TAX"},
"geo_dim": "geo",
"time_dim": "time",
},
"nrg_pc_203": {
# Gas prices for non-household consumers, EUR/kWh, excl. taxes
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
"geo_dim": "geo",
"time_dim": "time",
},
"lc_lci_lev": {
# Labour cost levels EUR/hour — NACE N (administrative/support services)
# D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
"filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
"geo_dim": "geo",
"time_dim": "time",
},
# ── Price level indices (relative scaling, EU27=100) ─────────────────────
# Five entries share the prc_ppp_ind dataset with different ppp_cat filters.
# dataset_code points to the real API endpoint; the dict key is the landing filename.
"prc_ppp_ind_construction": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A050202", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_housing": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A0104", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_services": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "P0201", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_misc": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "A0112", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
"prc_ppp_ind_government": {
"dataset_code": "prc_ppp_ind",
"filters": {"ppp_cat": "P0202", "na_item": "PLI_EU27_2020"},
"geo_dim": "geo",
"time_dim": "time",
},
}
@@ -196,22 +253,25 @@ def extract(
files_skipped = 0
bytes_written_total = 0
for dataset_code, config in DATASETS.items():
url = f"{EUROSTAT_BASE_URL}/{dataset_code}?format=JSON&lang=EN"
for dataset_key, config in DATASETS.items():
# Use dataset_code (if set) for the API URL; fall back to the dict key.
# This lets multiple entries share one Eurostat dataset with different filters.
api_code = config.get("dataset_code", dataset_key)
url = f"{EUROSTAT_BASE_URL}/{api_code}?format=JSON&lang=EN"
for key, val in config.get("filters", {}).items():
url += f"&{key}={val}"
dest_dir = landing_path(landing_dir, "eurostat", year, month)
dest = dest_dir / f"{dataset_code}.json.gz"
dest = dest_dir / f"{dataset_key}.json.gz"
logger.info("GET %s", dataset_code)
logger.info("GET %s", dataset_key)
bytes_written = _fetch_with_etag(url, dest, session, config)
if bytes_written > 0:
logger.info("%s updated — %s bytes compressed", dataset_code, f"{bytes_written:,}")
logger.info("%s updated — %s bytes compressed", dataset_key, f"{bytes_written:,}")
files_written += 1
bytes_written_total += bytes_written
else:
logger.info("%s not modified (304)", dataset_code)
logger.info("%s not modified (304)", dataset_key)
files_skipped += 1
return {

View File

@@ -19,7 +19,6 @@ Output: one JSON object per line, e.g.:
import gzip
import io
import json
import os
import sqlite3
import zipfile
@@ -28,7 +27,7 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import compress_jsonl_atomic, get_last_cursor, landing_path
from .utils import landing_path, skip_if_current, write_jsonl_atomic
logger = setup_logging("padelnomics.extract.geonames")
@@ -139,10 +138,10 @@ def extract(
tmp.rename(dest)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
if last_cursor == year_month:
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if skip:
logger.info("already have data for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
return skip
year, month = year_month.split("/")
@@ -168,11 +167,7 @@ def extract(
dest_dir = landing_path(landing_dir, "geonames", year, month)
dest = dest_dir / "cities_global.jsonl.gz"
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for row in rows:
f.write(json.dumps(row, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
bytes_written = write_jsonl_atomic(dest, rows)
logger.info("written %s bytes compressed", f"{bytes_written:,}")
return {

View File

@@ -17,7 +17,7 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
from .utils import get_last_cursor
from .utils import skip_if_current
logger = setup_logging("padelnomics.extract.gisco")
@@ -45,10 +45,10 @@ def extract(
session: niquests.Session,
) -> dict:
"""Download NUTS-2 GeoJSON. Skips if already run this month or file exists."""
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
if last_cursor == year_month:
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
if skip:
logger.info("already ran for %s — skipping", year_month)
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
return skip
dest = landing_dir / DEST_REL
if dest.exists():

View File

@@ -522,6 +522,10 @@ def extract_recheck(
dest_dir = landing_path(landing_dir, "playtomic", year, month)
dest = dest_dir / f"availability_{target_date}_recheck_{recheck_hour:02d}.jsonl.gz"
if not venues_data:
logger.warning("Recheck fetched 0 venues (%d errors) — skipping file write", venues_errored)
return {"files_written": 0, "files_skipped": 0, "bytes_written": 0}
captured_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
working_path = dest.with_suffix("").with_suffix(".working.jsonl")
with open(working_path, "w") as f:

View File

@@ -21,7 +21,6 @@ Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz
"""
import json
import os
import sqlite3
import time
@@ -33,7 +32,7 @@ import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
from .proxy import load_proxy_tiers, make_tiered_cycler
from .utils import compress_jsonl_atomic, landing_path
from .utils import landing_path, write_jsonl_atomic
logger = setup_logging("padelnomics.extract.playtomic_tenants")
@@ -215,11 +214,7 @@ def extract(
time.sleep(THROTTLE_SECONDS)
# Write each tenant as a JSONL line, then compress atomically
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for tenant in all_tenants:
f.write(json.dumps(tenant, separators=(",", ":")) + "\n")
bytes_written = compress_jsonl_atomic(working_path, dest)
bytes_written = write_jsonl_atomic(dest, all_tenants)
logger.info("%d unique venues -> %s", len(all_tenants), dest)
return {

View File

@@ -3,10 +3,9 @@
Proxies are configured via environment variables. When unset, all functions
return None/no-op — extractors fall back to direct requests.
Three-tier escalation: free → datacenter → residential.
Tier 1 (free): WEBSHARE_DOWNLOAD_URL — auto-fetched from Webshare API
Tier 2 (datacenter): PROXY_URLS_DATACENTER — comma-separated paid DC proxies
Tier 3 (residential): PROXY_URLS_RESIDENTIAL — comma-separated paid residential proxies
Two-tier escalation: datacenter → residential.
Tier 1 (datacenter): PROXY_URLS_DATACENTER — comma-separated paid DC proxies
Tier 2 (residential): PROXY_URLS_RESIDENTIAL — comma-separated paid residential proxies
Tiered circuit breaker:
Active tier is used until consecutive failures >= threshold, then escalates
@@ -69,22 +68,15 @@ def fetch_webshare_proxies(download_url: str, max_proxies: int = MAX_WEBSHARE_PR
def load_proxy_tiers() -> list[list[str]]:
"""Assemble proxy tiers in escalation order: free → datacenter → residential.
"""Assemble proxy tiers in escalation order: datacenter → residential.
Tier 1 (free): fetched from WEBSHARE_DOWNLOAD_URL if set.
Tier 2 (datacenter): PROXY_URLS_DATACENTER (comma-separated).
Tier 3 (residential): PROXY_URLS_RESIDENTIAL (comma-separated).
Tier 1 (datacenter): PROXY_URLS_DATACENTER (comma-separated).
Tier 2 (residential): PROXY_URLS_RESIDENTIAL (comma-separated).
Empty tiers are omitted. Returns [] if no proxies configured anywhere.
"""
tiers: list[list[str]] = []
webshare_url = os.environ.get("WEBSHARE_DOWNLOAD_URL", "").strip()
if webshare_url:
free_proxies = fetch_webshare_proxies(webshare_url)
if free_proxies:
tiers.append(free_proxies)
for var in ("PROXY_URLS_DATACENTER", "PROXY_URLS_RESIDENTIAL"):
raw = os.environ.get(var, "")
urls = [u.strip() for u in raw.split(",") if u.strip()]

View File

@@ -101,6 +101,19 @@ def get_last_cursor(conn: sqlite3.Connection, extractor: str) -> str | None:
return row["cursor_value"] if row else None
_SKIP_RESULT = {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
def skip_if_current(conn: sqlite3.Connection, extractor: str, year_month: str) -> dict | None:
"""Return an early-exit result dict if this extractor already ran for year_month.
Returns None when the extractor should proceed with extraction.
"""
if get_last_cursor(conn, extractor) == year_month:
return _SKIP_RESULT
return None
# ---------------------------------------------------------------------------
# File I/O helpers
# ---------------------------------------------------------------------------
@@ -176,6 +189,20 @@ def write_gzip_atomic(path: Path, data: bytes) -> int:
return len(compressed)
def write_jsonl_atomic(dest: Path, items: list[dict]) -> int:
"""Write items as JSONL, then compress atomically to dest (.jsonl.gz).
Compresses the working-file → JSONL → gzip pattern into one call.
Returns compressed bytes written.
"""
assert items, "items must not be empty"
working_path = dest.with_suffix(".working.jsonl")
with open(working_path, "w") as f:
for item in items:
f.write(json.dumps(item, separators=(",", ":")) + "\n")
return compress_jsonl_atomic(working_path, dest)
def compress_jsonl_atomic(jsonl_path: Path, dest_path: Path) -> int:
"""Compress a JSONL working file to .jsonl.gz atomically, then delete the source.

View File

@@ -33,10 +33,10 @@ do
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package padelnomics_extract extract
# Transform
# Transform — plan detects new/modified/deleted models and applies changes.
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package sqlmesh_padelnomics sqlmesh run --select-model "serving.*"
uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
# Export serving tables to analytics.duckdb (atomic swap).
# The web app detects the inode change on next query — no restart needed.

View File

@@ -8,54 +8,67 @@
# entry — optional: function name if not "main" (default: "main")
# depends_on — optional: list of workflow names that must run first
# proxy_mode — optional: "round-robin" (default) or "sticky"
# description — optional: human-readable one-liner shown in the admin UI
[overpass]
module = "padelnomics_extract.overpass"
schedule = "monthly"
description = "Padel court locations from OpenStreetMap via Overpass API"
[overpass_tennis]
module = "padelnomics_extract.overpass_tennis"
schedule = "monthly"
description = "Tennis court locations from OpenStreetMap via Overpass API"
[eurostat]
module = "padelnomics_extract.eurostat"
schedule = "monthly"
description = "City population data from Eurostat Urban Audit"
[geonames]
module = "padelnomics_extract.geonames"
schedule = "monthly"
description = "Global city/town gazetteer from GeoNames (pop >= 1K)"
[playtomic_tenants]
module = "padelnomics_extract.playtomic_tenants"
schedule = "daily"
description = "Padel venue directory from Playtomic (names, locations, courts)"
[playtomic_availability]
module = "padelnomics_extract.playtomic_availability"
schedule = "daily"
depends_on = ["playtomic_tenants"]
description = "Morning availability snapshots — slot-level pricing per venue"
[playtomic_recheck]
module = "padelnomics_extract.playtomic_availability"
entry = "main_recheck"
schedule = "0,30 6-23 * * *"
depends_on = ["playtomic_availability"]
description = "Intraday availability rechecks for occupancy tracking"
[census_usa]
module = "padelnomics_extract.census_usa"
schedule = "monthly"
description = "US city/place population from Census Bureau ACS"
[census_usa_income]
module = "padelnomics_extract.census_usa_income"
schedule = "monthly"
description = "US county median household income from Census Bureau ACS"
[eurostat_city_labels]
module = "padelnomics_extract.eurostat_city_labels"
schedule = "monthly"
description = "City code-to-name mapping for Eurostat Urban Audit cities"
[ons_uk]
module = "padelnomics_extract.ons_uk"
schedule = "monthly"
description = "UK local authority population estimates from ONS"
[gisco]
module = "padelnomics_extract.gisco"
schedule = "monthly"
schedule = "0 0 1 1 *"
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"

View File

@@ -1,4 +1,35 @@
# Building a Padel Hall — Complete Guide
# Padel Hall — Question Bank & Gap Analysis
> **What this file is**: A structured question bank covering the full universe of questions a padel hall entrepreneur needs to answer — from concept to exit. It is **not** an article for publication.
>
> **Purpose**: Gap analysis — identify which questions Padelnomics already answers (planner, city articles, pipeline data, business plan PDF) and which are unanswered gaps we could fill to improve product value.
>
> **Coverage legend**:
> - `ANSWERED` — fully covered by the planner, city articles, or BP export
> - `PARTIAL` — partially addressed; notable gap or missing depth
> - `GAP` — not addressed at all; actionable opportunity
---
## Gap Analysis Summary
| Tier | Gap | Estimated Impact | Status |
|------|-----|-----------------|--------|
| 1 | Subsidies & grants (Germany) | High | Not in product; data exists in `research/padel-hall-economics.md` |
| 1 | Buyer segmentation (sports club / commercial / hotel / franchise) | High | Not in planner; segmentation table exists in research |
| 1 | Indoor vs outdoor decision framework | High | Planner models both; no comparison table or decision guide |
| 1 | OPEX benchmarks shown inline | Medium-High | Planner has inputs; defaults not visually benchmarked |
| 2 | Booking platform strategy (Playtomic vs Matchi vs custom) | Medium | Zero guidance; we scrape Playtomic so know it well |
| 2 | Depreciation & tax shield | Medium | All calcs pre-tax; Germany: 30% effective, 7yr courts |
| 2 | Legal & regulatory checklist (Germany) | Medium | Only permit cost line; Bauantrag, TA Lärm, GmbH etc. missing |
| 2 | Court supplier selection framework | Medium | Supplier directory exists; no evaluation criteria |
| 2 | Staffing plan template | Medium | BP has narrative field; no structured role × FTE × salary |
| 3 | Zero-court location pages (white-space pSEO) | High data value | `location_opportunity_profile` scores them; none published |
| 3 | Pre-opening / marketing playbook | Low-Medium | Out of scope; static article possible |
| 3 | Catchment area isochrones (drive-time) | Low | Heavy lift; `nearest_padel_court_km` is straight-line only |
| 3 | Trend/fad risk quantification | Low | Inherently speculative |
---
## Table of Contents
@@ -16,6 +47,8 @@
### Market & Demand
> **COVERAGE: PARTIAL** — Venue counts, density (venues/100K), Market Score, and Opportunity Score per city are all answered by pipeline data (`location_opportunity_profile`) and surfaced in city articles. Missing: actual player counts, competitor utilization rates, household income / age demographics for the catchment area. No drive-time isochrone analysis (Tier 3 gap).
- How many padel players are in your target area? Is the sport growing locally or are you betting on future adoption?
- What's the competitive landscape — how many existing courts within a 2030 minute drive radius? Are they full? What are their peak/off-peak utilization rates?
- What's the demographic profile of your catchment area (income, age, sports participation)?
@@ -23,6 +56,8 @@
### Site & Location
> **COVERAGE: GAP** — The planner has a rent/land cost input and a `own` toggle for buy vs lease, but there is no guidance on site selection criteria (ceiling height, column spacing, zoning classification, parking ratios). A static article or checklist would cover this. See also Tier 2 gap: legal/regulatory checklist.
- Do you want to build new (greenfield), convert an existing building (warehouse, industrial hall), or add to an existing sports complex?
- What zoning and building regulations apply? Is a padel hall classified as sports, leisure, commercial?
- What's the required ceiling height? (Minimum ~810m for indoor padel, ideally 10m+)
@@ -30,6 +65,8 @@
### Product & Scope
> **COVERAGE: PARTIAL** — Court count is fully answered (planner supports 112 courts, sensitivity analysis included). Ancillary revenue streams (coaching, F&B, pro shop, events, memberships, corporate) are modelled. Indoor vs outdoor is modelled but there is no structured decision framework comparing CAPEX, revenue ceiling, seasonal risk, noise, and permits (Tier 1 gap #3). Quality level / positioning is not addressed.
- How many courts? (Typically 48 is the sweet spot for a standalone hall; fewer than 4 struggles with profitability, more than 8 requires very strong demand)
- Indoor only, outdoor, or hybrid with a retractable/seasonal structure?
- What ancillary offerings: pro shop, café/bar/lounge, fitness area, changing rooms, padel school/academy?
@@ -37,6 +74,8 @@
### Financial
> **COVERAGE: ANSWERED** — All four questions are directly answered by the planner: equity/debt split, rent/land cost, real peak/off-peak prices per city (from Playtomic via `planner_defaults`), utilization ramp curve (Year 15), and breakeven utilization (sensitivity grid).
- What's your total budget, and what's the split between equity and debt?
- What rental or land purchase cost can you sustain?
- What are realistic court booking prices in your market?
@@ -45,6 +84,8 @@
### Legal & Organizational
> **COVERAGE: GAP** — Only a permit cost line item exists in CAPEX. No entity guidance (GmbH vs UG vs Verein), no permit checklist, no license types, no insurance guidance. A Germany-first legal/regulatory checklist (Bauantrag, Nutzungsänderung, TA Lärm, Gewerbeerlaubnis, §4 Nr. 22 UStG sports VAT exemption) would be high-value static content (Tier 2 gap #7). Buyer segmentation (sports club vs. commercial) affects entity choice and grant eligibility (Tier 1 gap #2).
- What legal entity will you use?
- Do you need partners (operational, financial, franchise)?
- What permits, licenses, and insurance do you need?
@@ -56,6 +97,10 @@
### Phase 1: Feasibility & Concept (Month 13)
> **COVERAGE: ANSWERED** — This phase is fully supported. Market research → city articles (venue density, Market Score, Opportunity Score). Concept development → planner inputs. Location scouting → city articles + planner. Preliminary financial model → planner. Go/no-go → planner output (EBITDA, IRR, NPV).
>
> Missing: Buyer segmentation (Tier 1 gap #2) — the planner treats all users identically. A "project type" selector (sports club / commercial / hotel / franchise) would adjust CAPEX defaults, grant eligibility, and entity guidance.
1. **Market research**: Survey local players, visit competing facilities, analyze demographics within a 1520 minute drive radius. Talk to padel coaches and club organizers.
2. **Concept development**: Define your number of courts, target audience, service level, and ancillary revenue streams.
3. **Location scouting**: Identify 35 candidate sites. Evaluate each on accessibility, visibility, size, ceiling height (if conversion), zoning, and cost.
@@ -64,6 +109,8 @@
### Phase 2: Planning & Design (Month 36)
> **COVERAGE: PARTIAL** — Detailed financial model (step 9) and financing (step 10) are fully answered by the planner (DSCR, covenants, sensitivity). Court supplier selection (step 8) has a partial answer: a supplier directory exists in the product but there is no evaluation framework (Tier 2 gap #8: origin, price/court, warranty, glass type, installation, lead time). Permit process (step 11) is a gap (Tier 2 gap #7). Site security and architect hiring are operational advice, out of scope.
6. **Secure the site**: Sign a letter of intent or option agreement for purchase or lease.
7. **Hire an architect** experienced in sports facilities. They'll produce floor plans, elevations, structural assessments (for conversions), and MEP (mechanical, electrical, plumbing) layouts.
8. **Padel court supplier selection**: Get quotes from manufacturers (e.g., Mondo, Padelcreations, MejorSet). Courts come as prefabricated modules — coordinate dimensions, drainage, lighting, and glass specifications with your architect.
@@ -73,6 +120,8 @@
### Phase 3: Construction / Conversion (Month 612)
> **COVERAGE: PARTIAL** — Booking system (step 15) is partially addressed: booking system cost is a planner input, but there is no guidance on platform selection (Playtomic vs Matchi vs custom) despite this being a real decision with revenue and data implications (Tier 2 gap #5). Construction, installation, fit-out, and inspections are operational steps outside Padelnomics' scope.
12. **Tender and contract construction**: Either a general contractor or construction management approach. Key trades: structural/civil, flooring, HVAC (critical for indoor comfort), electrical (LED court lighting to specific lux standards), plumbing.
13. **Install padel courts**: Usually done after the building shell is complete. Courts take 24 weeks to install per batch.
14. **Fit-out ancillary areas**: Reception, changing rooms, lounge/bar, pro shop.
@@ -81,6 +130,8 @@
### Phase 4: Pre-Opening (Month 1013)
> **COVERAGE: PARTIAL** — Staffing plan (step 17): the BP export has a `staffing_plan` narrative field, but there is no structured template with role × FTE × salary defaults. Research benchmarks (€9.914.2K/month for 23 FTE + manager) could pre-fill this based on court count (Tier 2 gap #9). Marketing playbook (step 18): not addressed; could be a static article (Tier 3 gap #11). Soft/grand opening: out of scope.
17. **Hire staff**: Manager, reception, coaches, cleaning, potentially F&B staff.
18. **Marketing launch**: Social media, local partnerships (sports clubs, corporate wellness), opening event, introductory pricing.
19. **Soft opening**: Invite local players, influencers, press for a trial period.
@@ -88,6 +139,8 @@
### Phase 5: Operations & Optimization (Ongoing)
> **COVERAGE: PARTIAL** — Utilization monitoring and financial review are covered by the planner model. Upsell streams (coaching, equipment, F&B, memberships) are all revenue line items. Community building and dynamic pricing strategy are not addressed — these are operational, not data-driven, and are out of scope.
21. **Monitor utilization** by court, time slot, and day. Adjust pricing dynamically.
22. **Build community**: Leagues, tournaments, social events, corporate bookings.
23. **Upsell**: Coaching, equipment, food/beverage, memberships.
@@ -97,6 +150,8 @@
## Plans You Need to Create
> **COVERAGE: PARTIAL** — Business Plan and Financial Plan are both fully answered (planner + BP PDF export with 15+ narrative sections). Architectural Plans, Marketing Plan, and Legal/Permit Plan are outside the product's scope. Operational Plan is partial: staffing and booking system inputs exist but lack depth (Tier 2 gaps #5, #9).
- **Business Plan** — the master document covering market analysis, concept, operations plan, management team, and financials. This is what banks and investors want to see.
- **Architectural Plans** — floor plans, cross-sections, elevations, structural drawings, MEP plans. Required for permits and construction.
- **Financial Plan** — the core of your business plan. Includes investment budget, funding plan, P&L forecast (35 years), cash flow forecast, and sensitivity analysis.
@@ -112,6 +167,8 @@
### Investment Budget (CAPEX)
> **COVERAGE: ANSWERED** — The planner covers all 15+ CAPEX line items for both lease (`rent`) and purchase (`own`) scenarios. Subsidies and grants are **not** modelled (Tier 1 gap #1): `research/padel-hall-economics.md` documents Landessportbund grants (35% for sports clubs), KfW 150 loans, and a real example of €258K → €167K net after grant (padel-court.de). A "Fördermittel" (grants) section in the BP or a callout in DE city articles would surface this.
| Item | Estimate |
|---|---|
| Building lease deposit or land | €50,000€200,000 |
@@ -131,6 +188,8 @@ Realistic midpoint for a solid 6-court hall: **~€1.21.5M**.
### Revenue Model
> **COVERAGE: ANSWERED** — Court utilization × price per hour is the core model. Real peak/off-peak prices per city are pre-filled via `planner_defaults` from Playtomic data. Ramp curve (Year 15 utilization), 6 ancillary streams, and monthly seasonal curve are all modelled.
Core driver: **court utilization × price per hour**.
- 6 courts × 15 bookable hours/day × 365 days = **32,850 court-hours/year** (theoretical max)
@@ -149,6 +208,8 @@ Core driver: **court utilization × price per hour**.
### Operating Costs (OPEX)
> **COVERAGE: PARTIAL** — All OPEX line items exist as planner inputs. The defaults are reasonable but are not visually benchmarked against market data (Tier 1 gap #4). Research benchmarks from `research/padel-hall-economics.md` §7: electricity €2.54.5K/month, staff €9.914.2K/month for 23 FTE + manager, rent €815K/month. Showing "typical range for your market" next to each OPEX input field would improve trust in the defaults.
| Cost Item | Year 1 | Year 2 | Year 3 |
|---|---|---|---|
| Rent / lease | €120k | €123k | €127k |
@@ -164,6 +225,8 @@ Core driver: **court utilization × price per hour**.
### Profitability
> **COVERAGE: ANSWERED** — EBITDA, EBITDA margin, debt service, and free cash flow after debt are all computed by the planner for all 60 months.
| Metric | Year 1 | Year 2 | Year 3 |
|---|---|---|---|
| **EBITDA** | €310k | €577k | €759k |
@@ -173,6 +236,8 @@ Core driver: **court utilization × price per hour**.
### Key Metrics to Track
> **COVERAGE: ANSWERED** — Payback period, IRR (equity + project), NPV, MOIC, DSCR per year, breakeven utilization, and revenue per available hour are all computed and displayed.
- **Payback period**: Typically 35 years for a well-run padel hall
- **ROI on equity**: If you put in €500k equity and generate €300k+ annual free cash flow by year 3, that's a 60%+ cash-on-cash return
- **Breakeven utilization**: Usually around 3540% — below which you lose money
@@ -180,12 +245,18 @@ Core driver: **court utilization × price per hour**.
### Sensitivity Analysis
> **COVERAGE: ANSWERED** — 12-step utilization sensitivity and 8-step price sensitivity are both shown as grids, each including DSCR values.
Model what happens if utilization is 10% lower than planned, if the average price drops by €5, or if construction costs overrun by 20%. This is what banks want to see — that you survive the downside.
---
## How to Decide Where to Build
> **COVERAGE: PARTIAL overall** — The product answers competition mapping (venue density, Opportunity Score) and rent/cost considerations (planner input). Missing: drive-time catchment analysis (Tier 3 gap #12 — would need isochrone API), accessibility/visibility/building suitability assessment (static checklist possible), growth trajectory (no new-development data), and regulatory environment (Tier 2 gap #7).
>
> **Tier 3 opportunity**: `location_opportunity_profile` scores thousands of GeoNames locations including zero-court towns. Only venues with existing courts get a public article. Generating pSEO pages for top-scoring zero-court locations would surface "build here" recommendations (white-space pages).
1. **Catchment area analysis**: Draw a 15-minute and 30-minute drive-time radius around candidate sites. Analyze population density, household income, age distribution (2555 is the core padel demographic), and existing sports participation rates.
2. **Competition mapping**: Map every existing padel facility within 30 minutes. Call them, check their booking systems — are courts booked out at peak? If competitors are running at 80%+ utilization, that's a strong signal of unmet demand.
@@ -208,70 +279,104 @@ Model what happens if utilization is 10% lower than planned, if the average pric
### NPV & IRR
> **COVERAGE: ANSWERED** — Both equity IRR and project IRR are computed. NPV is shown with the WACC input. Hurdle rate is a user input.
Discount your projected free cash flows at your WACC (or required return on equity if all-equity financed) to get a net present value. The IRR tells you whether the project clears your hurdle rate. For a padel hall, you'd typically want an unlevered IRR of 1525% to justify the risk of a single-asset, operationally intensive business. Compare this against alternative uses of your capital.
### WACC & Cost of Capital
> **COVERAGE: ANSWERED** — WACC is a planner input used in NPV calculations. Debt cost and equity cost are separately configurable.
If you're blending debt and equity, calculate your weighted average cost of capital properly. Bank debt for a sports facility might run 47% depending on jurisdiction and collateral. Your equity cost should reflect the illiquidity premium and operational risk — this isn't a passive real estate investment, it's an operating business. A reasonable cost of equity might be 1220%.
### Terminal Value
> **COVERAGE: ANSWERED** — Terminal value is computed as EBITDA × exit multiple at the end of the hold period. MOIC and value bridge are displayed.
If you model 5 years of explicit cash flows, you need a terminal value. You can use a perpetuity growth model (FCF year 5 × (1+g) / (WACC g)) or an exit multiple. For the exit multiple approach, think about what a buyer would pay — likely 47x EBITDA for a mature, well-run single-location padel hall, potentially higher if it's part of a multi-site rollout story.
### Lease vs. Buy
> **COVERAGE: ANSWERED** — The `own` toggle in the planner changes the entire CAPEX/OPEX structure: land purchase replaces lease deposit, mortgage replaces rent, and property appreciation is modelled in terminal value.
A critical capital allocation decision. Buying the property ties up far more capital but gives you residual asset value and eliminates landlord risk. Leasing preserves capital for operations and expansion but exposes you to rent increases and lease termination risk. Model both scenarios and compare the risk-adjusted NPV. Also consider sale-and-leaseback if you build on owned land.
### Operating Leverage
> **COVERAGE: ANSWERED** — The sensitivity grids explicitly show how a 10% utilization swing affects EBITDA and DSCR.
A padel hall has high fixed costs (rent, staff base, debt service) and relatively low variable costs. This means profitability is extremely sensitive to utilization. Model the operating leverage explicitly — a 10% swing in utilization might cause a 2530% swing in EBITDA. This is both the opportunity and the risk.
### Depreciation & Tax Shield
> **COVERAGE: GAP** — All planner calculations are pre-tax (Tier 2 gap #6). Adding a depreciation schedule and effective tax rate would materially improve the financial model for Germany: 7-year depreciation for courts/equipment, ~30% effective tax rate (15% KSt + 14% GewSt). This would require jurisdiction selection (start with Germany only). Non-trivial but the most common user geography.
Padel courts depreciate over 710 years, building fit-out over 1015 years, equipment over 35 years. The depreciation tax shield is meaningful. Interest expense on debt is also tax-deductible. Model your effective tax rate and the present value of these shields — they improve your after-tax returns materially.
### Working Capital Cycle
> **COVERAGE: ANSWERED** — Pre-opening cash burn and ramp-up period are modelled in the 60-month cash flow. Working capital reserve is a CAPEX line item.
Padel halls are generally working-capital-light (customers pay at booking or on arrival, you pay suppliers on 3060 day terms). But model the initial ramp-up period where you're carrying costs before revenue reaches steady state. The pre-opening cash burn and first 612 months of sub-breakeven operation is where most of your working capital risk sits.
### Scenario & Sensitivity Analysis
> **COVERAGE: ANSWERED** — Utilization sensitivity (12 steps) and price sensitivity (8 steps) grids are shown, both with DSCR. Bear/base/bull narrative is covered in the BP export.
Model three scenarios (bear/base/bull) varying utilization, pricing, and cost overruns simultaneously. Identify the breakeven utilization rate precisely. A Monte Carlo simulation on the key variables (utilization, average price, construction cost, ramp-up speed) gives you a probability distribution of outcomes rather than a single point estimate.
### Exit Strategy & Valuation
> **COVERAGE: ANSWERED** — Hold period, exit EBITDA multiple, terminal value, MOIC, and value bridge are all displayed in the planner.
Think about this upfront. Are you building to hold and cash-flow, or building to sell to a consolidator or franchise operator? The exit multiple depends heavily on whether you've built a transferable business (brand, systems, trained staff, long lease) or an owner-dependent operation. Multi-site operators and franchise groups trade at higher multiples (610x EBITDA) than single sites.
### Optionality Value
> **COVERAGE: GAP** — Real option value (second location, franchise, repurposing) is mentioned in the BP narrative but not quantified. Out of scope for the planner; noting as a caveat in the BP export text would be sufficient.
A successful first hall gives you the option to expand — second location, franchise model, or selling the playbook. This real option has value that a static DCF doesn't capture. Similarly, if you own the land/building, you have conversion optionality (the building could be repurposed if padel demand fades).
### Counterparty & Concentration Risk
> **COVERAGE: PARTIAL** — The planner models this implicitly (single-site, single-sport), and DSCR warnings flag over-leverage. No explicit counterparty risk section. Mentioning it in the BP risk narrative would be low-effort coverage.
You're exposed to a single landlord (lease risk), a single location (demand risk), and potentially a single sport (trend risk). A bank or sophisticated investor will flag all three. Mitigants include long lease terms with caps on escalation, diversified revenue streams (F&B, events, coaching), and contractual protections.
### Subsidies & Grants
> **COVERAGE: GAP — Tier 1 priority.** `research/padel-hall-economics.md` documents: Landessportbund grants (up to 35% CAPEX for registered sports clubs), KfW 150 low-interest loans, and a worked example: €258K gross → €167K net CAPEX after grant. The planner has no grants input. Quick wins: (a) add a "Fördermittel" accordion section to DE city articles; (b) add a grant percentage input to the planner CAPEX section (reduces total investment and boosts IRR). Note: grant eligibility depends on buyer type (Tier 1 gap #2) — sports clubs qualify, commercial operators typically do not.
Many municipalities and national sports bodies offer grants or subsidized loans for sports infrastructure. In some European countries, this can cover 1030% of CAPEX. Factor this into your funding plan — it's essentially free equity that boosts your returns.
### VAT & Tax Structuring
> **COVERAGE: GAP** — Not modelled. Germany-specific: court rental may qualify for §4 Nr. 22 UStG sports VAT exemption (0% VAT) if operated by a non-commercial entity; commercial operators pay 19% VAT on court rental. F&B is 19% (or 7% eat-in). Getting this wrong materially affects revenue net-of-VAT. Worth a callout in the legal/regulatory article (Tier 2 gap #7).
Depending on your jurisdiction, court rental may be VAT-exempt or reduced-rate (sports exemption), while F&B is standard-rated. This affects pricing strategy and cash flow. The entity structure (single GmbH, holding structure, partnership) has implications for profit extraction, liability, and eventual exit taxation. Worth getting tax advice early.
### Insurance & Business Interruption
> **COVERAGE: PARTIAL** — Insurance is a planner OPEX line item. No guidance on coverage types or BI insurance sizing. Low priority to expand.
Price in comprehensive insurance — property, liability, business interruption. A fire or structural issue that shuts you down for 3 months could be existential without BI coverage. This is a real cost that's often underestimated.
### Covenant Compliance
> **COVERAGE: ANSWERED** — DSCR is computed for each of the 5 years and shown with a warning band. LTV warnings are also displayed.
If you take bank debt, you'll likely face covenants — DSCR (debt service coverage ratio) minimums of 1.21.5x, leverage caps, possibly revenue milestones. Model your covenant headroom explicitly. Breaching a covenant in year 1 during ramp-up is a real risk if you've over-leveraged.
### Inflation Sensitivity
> **COVERAGE: ANSWERED** — The planner has separate `revenue_growth_rate` and `opex_growth_rate` inputs, allowing asymmetric inflation scenarios.
Energy costs, staff wages, and maintenance all inflate. Can you pass these through via price increases without killing utilization? Model a scenario where costs inflate at 35% but you can only raise prices by 23%.
### Residual / Liquidation Value
> **COVERAGE: PARTIAL** — Terminal/exit value is modelled (EBITDA multiple). A true liquidation scenario (courts resale, lease termination penalties, building write-off) is not separately modelled. Sufficient for the current product.
In a downside scenario, what are your assets worth? Padel courts have some resale value. Building improvements are largely sunk. If you've leased, your downside is limited to equity invested plus any personal guarantees. If you've bought property, the real estate retains value but may take time to sell. Model the liquidation scenario honestly.
---
@@ -280,24 +385,34 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
### Existential Risks
> **COVERAGE: PARTIAL** — Trend/fad risk is acknowledged in the BP narrative but not quantified (Tier 3 gap #13). FIP/Playtomic data (7,187 new courts globally in 2024, +26% YoY new clubs) exists but long-term quantification is inherently speculative. Force majeure/pandemic risk is not addressed; a reserve fund input (CAPEX working capital) provides partial mitigation modelling.
- **Trend / Fad Risk**: Padel is booming now, but so did squash in the 1980s. You're locking in a 1015 year investment thesis on a sport that may plateau or decline. The key question is whether padel reaches self-sustaining critical mass in your market or stays a novelty. If utilization drops from 65% to 35% in year 5 because the hype fades, your entire model breaks. This is largely unhedgeable.
- **Force Majeure / Pandemic Risk**: COVID shut down indoor sports facilities for months. Insurance may not cover it. Having enough cash reserves or credit facilities to survive 36 months of zero revenue is prudent.
### Construction & Development Risks
> **COVERAGE: PARTIAL** — A contingency/overrun percentage is a planner CAPEX input. Delay cost (carrying costs during construction) is not explicitly modelled.
- **Construction Cost Overruns & Delays**: Sports facility builds routinely overrun by 1530%. Every month of delay is a month of carrying costs (rent, debt service, staff already hired) with zero revenue. Build a contingency buffer of 1520% of CAPEX minimum and negotiate fixed-price construction contracts where possible.
### Property & Lease Risks
> **COVERAGE: GAP** — No lease-term inputs or landlord risk guidance. The `own` toggle handles the buy scenario. A callout in the BP template about minimum lease length (15+ years, renewal options) would be useful but is low priority.
- **Landlord Risk**: If you're leasing, you're spending €500k+ fitting out someone else's building. What happens if the landlord sells, goes bankrupt, or refuses to renew? You need a long lease (15+ years), with options to renew, and ideally a step-in right or compensation clause for tenant improvements.
### Competitive Risks
> **COVERAGE: PARTIAL** — City articles show existing venue density and Opportunity Score. The planner does not model a "competitor opens nearby" scenario. A simple sensitivity scenario (utilization drop) is the best proxy available in the current model.
- **Cannibalization from New Entrants**: Your success is visible — full courts, long waitlists. This attracts competitors. Someone opens a new hall 10 minutes away, and your utilization drops from 70% to 50%. There's no real moat in padel besides location, community loyalty, and service quality. Model what happens when a competitor opens nearby in year 3.
### Operational Risks
> **COVERAGE: PARTIAL** — Court maintenance OPEX and maintenance reserve are planner inputs. F&B, staffing, and booking platform risks are not addressed. See Tier 2 gaps #5 (booking platform strategy) and #9 (staffing plan). Seasonality is fully modelled (12-month outdoor seasonal curve; monthly cash flow).
- **Key Person Dependency**: If the whole operation depends on one founder-operator or one star coach who brings all the members, that's a fragility. Illness, burnout, or departure can crater the business.
- **Staff Retention & Labor Market**: Good facility managers, coaches, and front-desk staff with a hospitality mindset are hard to find and keep. Turnover is expensive and disruptive. In tight labor markets, wage pressure can erode margins.
@@ -310,6 +425,8 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
### Financial Risks
> **COVERAGE: PARTIAL** — Energy volatility: energy OPEX is a modelled input with growth rate, but no locking/hedging guidance. Financing environment: debt rate is a planner input; stress-test at +2% is covered by the sensitivity grid indirectly. Personal guarantee and customer concentration: not addressed (out of scope for data-driven product). Inflation pass-through: answered (separate revenue vs OPEX growth rates).
- **Energy Price Volatility**: Indoor padel halls consume significant energy. Energy costs spiking can destroy margins. Consider locking in energy contracts, investing in solar panels, or using LED lighting and efficient HVAC to reduce exposure.
- **Financing Environment**: If interest rates rise between when you plan the project and when you draw down the loan, your debt service costs increase. Lock in rates where possible, or stress-test your model at rates 2% higher than current.
@@ -322,22 +439,32 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
### Regulatory & Legal Risks
> **COVERAGE: GAP — Tier 2 priority.** Noise complaints (TA Lärm), injury liability, and permit risks are all unaddressed. A Germany-first regulatory checklist article would cover: Bauantrag, Nutzungsänderung, TA Lärm compliance, GmbH vs UG formation, Gewerbeerlaubnis, §4 Nr. 22 UStG sports VAT, and Gaststättengesetz (liquor license). High value for Phase 1/2 users who are evaluating feasibility.
- **Noise Complaints**: Padel is loud — the ball hitting glass walls generates significant noise. Neighbors can complain and municipal authorities can impose operating hour restrictions or require expensive sound mitigation. Check local noise ordinances thoroughly before committing.
- **Injury Liability**: Padel involves glass walls, fast-moving balls, and quick lateral movement. Player injuries happen. Proper insurance, waiver systems, and court maintenance protocols are essential.
### Technology & Platform Risks
> **COVERAGE: GAP — Tier 2 priority.** Booking platform dependency is a real decision point for operators (Playtomic commission ~1520%, data ownership implications, competitor steering risk). We scrape Playtomic and know it intimately. A standalone article "Playtomic vs Matchi vs eigenes System" or a section in the BP template would address this. The booking system commission rate is already a planner input — we could link to a decision guide from there.
- **Booking Platform Dependency**: If you rely on a third-party booking platform like Playtomic, you're giving them access to your customer relationships and paying commission. They could raise fees, change terms, or steer demand to competitors.
### Reputational Risks
> **COVERAGE: GAP** — Not addressed. Out of scope for a data-driven product; operational advice.
- **Brand / Reputation Risk**: One viral negative review, a hygiene issue, a safety incident, or a social media complaint can disproportionately hurt a local leisure business.
### Currency & External Risks
> **COVERAGE: GAP** — FX risk from Spanish/Italian manufacturers is not modelled. Minor; most German buyers pay in EUR. Note in BP template as a caveat if importing outside Eurozone.
- **Currency Risk**: Relevant if importing courts or equipment from another currency zone — padel court manufacturers are often Spanish or Italian, so FX moves can affect CAPEX if you're outside the Eurozone.
### Opportunity Cost
> **COVERAGE: PARTIAL** — IRR and NPV implicitly address opportunity cost (you enter the hurdle rate as WACC/cost of equity). No explicit comparison against passive investment alternatives is shown. Sufficient for current product.
The capital, time, and energy you put into this project could go elsewhere. If you could earn 810% passively in diversified investments, a padel hall needs to deliver meaningfully more on a risk-adjusted basis to justify the concentration, illiquidity, and personal time commitment.

290
scripts/check_pipeline.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Diagnostic script: check row counts at every layer of the pricing pipeline.
Run on prod via SSH:
DUCKDB_PATH=/opt/padelnomics/data/lakehouse.duckdb uv run python scripts/check_pipeline.py
Or locally:
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
Read-only — never writes to the database.
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
"""
import os
import sys
import duckdb
DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
PIPELINE_TABLES = [
("staging", "stg_playtomic_availability"),
("foundation", "fct_availability_slot"),
("foundation", "dim_venue_capacity"),
("foundation", "fct_daily_availability"),
("serving", "venue_pricing_benchmarks"),
("serving", "pseo_city_pricing"),
]
def _use_catalog(con):
"""Detect and USE the database catalog so schema-qualified queries work."""
catalogs = [
row[0]
for row in con.execute(
"SELECT catalog_name FROM information_schema.schemata"
).fetchall()
]
# Pick the non-system catalog (not 'system', 'temp', 'memory')
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
if user_catalogs:
catalog = user_catalogs[0]
con.execute(f"USE {catalog}")
return catalog
return None
def _find_physical_table(con, schema, table):
"""Find the SQLMesh physical table name for a logical table.
SQLMesh stores physical tables as:
sqlmesh__<schema>.<schema>__<table>__<hash>
"""
sqlmesh_schema = f"sqlmesh__{schema}"
try:
rows = con.execute(
"SELECT table_schema, table_name "
"FROM information_schema.tables "
f"WHERE table_schema = '{sqlmesh_schema}' "
f"AND table_name LIKE '{schema}__{table}%' "
"ORDER BY table_name "
"LIMIT 1"
).fetchall()
if rows:
return f"{rows[0][0]}.{rows[0][1]}"
except Exception:
pass
return None
def _query_table(con, schema, table):
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
logical = f"{schema}.{table}"
try:
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
return logical, count
except Exception:
pass
physical = _find_physical_table(con, schema, table)
if physical:
try:
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
return f"{physical} (physical)", count
except Exception as e:
return f"{physical} (physical)", f"ERROR: {e}"
return logical, "ERROR: view broken, no physical table found"
def _query_sql(con, sql, schema_tables):
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
The SQL must use {schema}.{table} format for these references.
"""
try:
return con.execute(sql)
except Exception:
# Rewrite SQL to use physical table names
rewritten = sql
for schema, table in schema_tables:
physical = _find_physical_table(con, schema, table)
if physical:
rewritten = rewritten.replace(f"{schema}.{table}", physical)
else:
raise
return con.execute(rewritten)
def main():
if not os.path.exists(DUCKDB_PATH):
print(f"ERROR: {DUCKDB_PATH} not found")
sys.exit(1)
con = duckdb.connect(DUCKDB_PATH, read_only=True)
print(f"Database: {DUCKDB_PATH}")
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
catalog = _use_catalog(con)
if catalog:
print(f"Catalog: {catalog}")
print()
# ── Row counts at each layer ──────────────────────────────────────────
print("=" * 60)
print("PIPELINE ROW COUNTS")
print("=" * 60)
for schema, table in PIPELINE_TABLES:
fqn, result = _query_table(con, schema, table)
if isinstance(result, int):
print(f" {fqn:55s} {result:>10,} rows")
else:
print(f" {fqn:55s} {result}")
# ── Date range in fct_daily_availability ──────────────────────────────
print()
print("=" * 60)
print("DATE RANGE: fct_daily_availability")
print("=" * 60)
try:
row = _query_sql(
con,
"""
SELECT
MIN(snapshot_date) AS min_date,
MAX(snapshot_date) AS max_date,
COUNT(DISTINCT snapshot_date) AS distinct_days,
CURRENT_DATE AS today,
CURRENT_DATE - INTERVAL '30 days' AS window_start
FROM foundation.fct_daily_availability
""",
[("foundation", "fct_daily_availability")],
).fetchone()
if row:
min_date, max_date, days, today, window_start = row
print(f" Min snapshot_date: {min_date}")
print(f" Max snapshot_date: {max_date}")
print(f" Distinct days: {days}")
print(f" Today: {today}")
print(f" 30-day window start: {window_start}")
if max_date and str(max_date) < str(window_start):
print()
print(" *** ALL DATA IS OUTSIDE THE 30-DAY WINDOW ***")
print(" This is why venue_pricing_benchmarks is empty.")
except Exception as e:
print(f" ERROR: {e}")
# ── HAVING filter impact in venue_pricing_benchmarks ──────────────────
print()
print("=" * 60)
print("HAVING FILTER IMPACT (venue_pricing_benchmarks)")
print("=" * 60)
try:
row = _query_sql(
con,
"""
WITH venue_stats AS (
SELECT
da.tenant_id,
da.country_code,
da.city,
COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
AND da.occupancy_rate IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5
GROUP BY da.tenant_id, da.country_code, da.city
)
SELECT
COUNT(*) AS total_venues,
COUNT(*) FILTER (WHERE days_observed >= 3) AS venues_passing_having,
COUNT(*) FILTER (WHERE days_observed < 3) AS venues_failing_having,
MAX(days_observed) AS max_days,
MIN(days_observed) AS min_days
FROM venue_stats
""",
[("foundation", "fct_daily_availability")],
).fetchone()
if row:
total, passing, failing, max_d, min_d = row
print(f" Venues in 30-day window: {total}")
print(f" Venues with >= 3 days (PASSING): {passing}")
print(f" Venues with < 3 days (FILTERED): {failing}")
print(f" Max days observed: {max_d}")
print(f" Min days observed: {min_d}")
if total == 0:
print()
print(" *** NO VENUES IN 30-DAY WINDOW — check fct_daily_availability dates ***")
except Exception as e:
print(f" ERROR: {e}")
# ── Occupancy rate distribution ───────────────────────────────────────
print()
print("=" * 60)
print("OCCUPANCY RATE DISTRIBUTION (fct_daily_availability)")
print("=" * 60)
try:
rows = _query_sql(
con,
"""
SELECT
CASE
WHEN occupancy_rate IS NULL THEN 'NULL'
WHEN occupancy_rate < 0 THEN '< 0 (invalid)'
WHEN occupancy_rate > 1.5 THEN '> 1.5 (filtered)'
WHEN occupancy_rate <= 0.25 THEN '0 0.25'
WHEN occupancy_rate <= 0.50 THEN '0.25 0.50'
WHEN occupancy_rate <= 0.75 THEN '0.50 0.75'
ELSE '0.75 1.0+'
END AS bucket,
COUNT(*) AS cnt
FROM foundation.fct_daily_availability
GROUP BY 1
ORDER BY 1
""",
[("foundation", "fct_daily_availability")],
).fetchall()
for bucket, cnt in rows:
print(f" {bucket:25s} {cnt:>10,}")
except Exception as e:
print(f" ERROR: {e}")
# ── dim_venue_capacity join coverage ──────────────────────────────────
print()
print("=" * 60)
print("JOIN COVERAGE: fct_availability_slot → dim_venue_capacity")
print("=" * 60)
try:
row = _query_sql(
con,
"""
SELECT
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
FROM foundation.fct_availability_slot a
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
""",
[
("foundation", "fct_availability_slot"),
("foundation", "dim_venue_capacity"),
],
).fetchone()
if row:
slot_t, cap_t, missing = row
print(f" Tenants in fct_availability_slot: {slot_t}")
print(f" Tenants with capacity match: {cap_t}")
print(f" Tenants missing capacity: {missing}")
if missing and missing > 0:
print(f" *** {missing} tenants dropped by INNER JOIN to dim_venue_capacity ***")
except Exception as e:
print(f" ERROR: {e}")
con.close()
print()
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,553 @@
"""
E2E test for checkout.session.completed webhook → transaction.completed handler.
Tests credit packs, sticky boosts, and business plan PDF purchases by:
1. Constructing realistic checkout.session.completed payloads with our real price IDs
2. Signing them with the active webhook secret
3. POSTing to the running dev server
4. Verifying DB state changes (credit_balance, supplier_boosts, business_plan_exports)
Prerequisites:
- ngrok + webhook endpoint registered (stripe_e2e_setup.py)
- Dev server running with webhook secret loaded
- Stripe products synced (setup_stripe --sync)
Run: uv run python scripts/stripe_e2e_checkout_test.py
"""
import hashlib
import hmac
import json
import os
import sqlite3
import subprocess
import sys
import time
from dotenv import load_dotenv
load_dotenv(override=True)
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
SERVER_URL = "http://localhost:5000"
WEBHOOK_URL = f"{SERVER_URL}/billing/webhook/stripe"
assert WEBHOOK_SECRET, "STRIPE_WEBHOOK_SECRET not set — run stripe_e2e_setup.py"
passed = 0
failed = 0
errors = []
def ok(msg):
global passed
passed += 1
print(f" \u2713 {msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f" \u2717 {msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
def query_db(sql, params=()):
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
finally:
conn.close()
def sign_stripe_payload(payload_bytes: bytes, secret: str) -> str:
"""Create a valid Stripe-Signature header."""
timestamp = str(int(time.time()))
signed_payload = f"{timestamp}.{payload_bytes.decode()}"
sig = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={sig}"
def post_webhook(event_type: str, obj: dict) -> int:
"""Post a signed webhook to the server. Returns HTTP status code."""
payload = json.dumps({
"id": f"evt_test_{int(time.time()*1000)}",
"type": event_type,
"data": {"object": obj},
}).encode()
sig = sign_stripe_payload(payload, WEBHOOK_SECRET)
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/json",
"-H", f"Stripe-Signature: {sig}",
"--data-binary", "@-",
WEBHOOK_URL],
input=payload.decode(), capture_output=True, text=True, timeout=10,
)
return int(result.stdout.strip())
# ─── Preflight ────────────────────────────────────────────
section("Preflight")
# Server up
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() in ("200", "301"), f"Server down ({result.stdout})"
ok("Dev server running")
# Webhook active
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", "Content-Type: application/json", "-d", "{}",
WEBHOOK_URL],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout} (expected 400)"
ok("Webhook signature check active")
# Load price IDs
products = query_db("SELECT key, provider_price_id FROM payment_products WHERE provider = 'stripe'")
price_map = {p["key"]: p["provider_price_id"] for p in products}
ok(f"Loaded {len(price_map)} products")
# Test data
users = query_db("SELECT id, email FROM users LIMIT 5")
test_user = users[0]
ok(f"User: {test_user['email']} (id={test_user['id']})")
suppliers = query_db("SELECT id, name, credit_balance FROM suppliers WHERE claimed_by IS NOT NULL LIMIT 1")
assert suppliers, "No claimed supplier found"
test_supplier = suppliers[0]
initial_balance = test_supplier["credit_balance"]
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, balance={initial_balance})")
# ═══════════════════════════════════════════════════════════
# Test 1: Credit Pack purchases (all 4 sizes)
# ═══════════════════════════════════════════════════════════
section("1. Credit Pack purchases via checkout.session.completed")
credit_packs = [
("credits_25", 25),
("credits_50", 50),
("credits_100", 100),
("credits_250", 250),
]
running_balance = initial_balance
for key, amount in credit_packs:
price_id = price_map.get(key)
if not price_id:
fail(f"{key}: price not found")
continue
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_{key}_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_credits",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": key,
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok(f"{key}: webhook accepted (HTTP 200)")
else:
fail(f"{key}: webhook returned HTTP {status}")
continue
# Wait and check balance
time.sleep(2)
rows = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))
new_balance = rows[0]["credit_balance"] if rows else -1
expected = running_balance + amount
if new_balance == expected:
ok(f"{key}: balance {running_balance}{new_balance} (+{amount})")
running_balance = new_balance
else:
fail(f"{key}: balance {new_balance}, expected {expected}")
running_balance = new_balance # update anyway for next test
# Check ledger entries
ledger = query_db(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'pack_purchase' ORDER BY id DESC LIMIT 4",
(test_supplier["id"],),
)
if len(ledger) >= 4:
ok(f"Credit ledger: {len(ledger)} pack_purchase entries")
else:
fail(f"Credit ledger: only {len(ledger)} entries (expected 4)")
# ═══════════════════════════════════════════════════════════
# Test 2: Sticky Boost purchases
# ═══════════════════════════════════════════════════════════
section("2. Sticky boost purchases")
# 2a. Sticky Week
price_id = price_map.get("boost_sticky_week")
if price_id:
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_sticky_week_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_sticky",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_week",
"sticky_country": "DE",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("boost_sticky_week: webhook accepted")
else:
fail(f"boost_sticky_week: HTTP {status}")
time.sleep(2)
# Check supplier_boosts
boosts = query_db(
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_week' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
if boosts:
b = boosts[0]
ok(f"supplier_boosts row: type=sticky_week, status={b['status']}")
if b.get("expires_at"):
ok(f"expires_at set: {b['expires_at']}")
else:
fail("expires_at is NULL")
else:
fail("No supplier_boosts row for sticky_week")
# Check suppliers.sticky_until
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_until"]:
ok(f"sticky_until set: {sup[0]['sticky_until']}")
else:
fail("sticky_until not set")
if sup and sup[0]["sticky_country"] == "DE":
ok("sticky_country=DE")
else:
fail(f"sticky_country={sup[0]['sticky_country'] if sup else '?'}")
else:
fail("boost_sticky_week price not found")
# 2b. Sticky Month
price_id = price_map.get("boost_sticky_month")
if price_id:
# Reset sticky fields
conn = sqlite3.connect(DATABASE_PATH)
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
conn.commit()
conn.close()
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_sticky_month_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_sticky",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_month",
"sticky_country": "ES",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("boost_sticky_month: webhook accepted")
else:
fail(f"boost_sticky_month: HTTP {status}")
time.sleep(2)
boosts = query_db(
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_month' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
if boosts:
ok(f"supplier_boosts row: type=sticky_month, expires_at={boosts[0].get('expires_at', '?')[:10]}")
else:
fail("No supplier_boosts row for sticky_month")
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_country"] == "ES":
ok("sticky_country=ES (month)")
else:
fail(f"sticky_country wrong: {sup[0] if sup else '?'}")
else:
fail("boost_sticky_month price not found")
# ═══════════════════════════════════════════════════════════
# Test 3: Business Plan PDF purchase
# ═══════════════════════════════════════════════════════════
section("3. Business Plan PDF purchase")
price_id = price_map.get("business_plan")
if price_id:
# Create a scenario for the user first
conn = sqlite3.connect(DATABASE_PATH)
conn.execute(
"INSERT INTO scenarios (user_id, name, state_json, created_at) VALUES (?, 'Test', '{}', datetime('now'))",
(test_user["id"],),
)
conn.commit()
scenario_row = conn.execute("SELECT id FROM scenarios WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(test_user["id"],)).fetchone()
scenario_id = scenario_row[0] if scenario_row else 0
conn.close()
ok(f"Created test scenario: id={scenario_id}")
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_bp_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_bp",
"metadata": {
"user_id": str(test_user["id"]),
"plan": "business_plan",
"scenario_id": str(scenario_id),
"language": "de",
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
if status == 200:
ok("business_plan: webhook accepted")
else:
fail(f"business_plan: HTTP {status}")
time.sleep(2)
# Check business_plan_exports
exports = query_db(
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY id DESC LIMIT 1",
(test_user["id"],),
)
if exports:
e = exports[0]
ok(f"Export row: status={e['status']}, language={e['language']}")
if e["status"] == "pending":
ok("Status: pending (waiting for worker)")
else:
print(f" ? Status: {e['status']} (expected pending)")
if e["language"] == "de":
ok("Language: de")
else:
fail(f"Language: {e['language']} (expected de)")
if e.get("token"):
ok(f"Download token generated: {e['token'][:10]}...")
else:
fail("No download token")
if e.get("scenario_id") == scenario_id:
ok(f"Scenario ID matches: {scenario_id}")
else:
fail(f"Scenario ID: {e.get('scenario_id')} (expected {scenario_id})")
else:
fail("No business_plan_exports row created")
else:
fail("business_plan price not found")
# ═══════════════════════════════════════════════════════════
# Test 4: Edge cases
# ═══════════════════════════════════════════════════════════
section("4a. Edge: checkout.session.completed with unknown price_id")
status = post_webhook("checkout.session.completed", {
"id": "cs_test_unknown",
"mode": "payment",
"customer": "cus_test_unknown",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "nonexistent_product",
},
"line_items": {"data": [{"price": {"id": "price_nonexistent"}, "quantity": 1}]},
})
ok(f"Unknown price: HTTP {status} (no crash)") if status == 200 else fail(f"Unknown price: HTTP {status}")
# Server alive?
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
ok("Server alive after unknown price") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
section("4b. Edge: checkout.session.completed with missing supplier_id (credit pack)")
balance_before = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
status = post_webhook("checkout.session.completed", {
"id": "cs_test_no_supplier",
"mode": "payment",
"customer": "cus_test_nosup",
"metadata": {
"user_id": str(test_user["id"]),
# NO supplier_id
"plan": "credits_25",
},
"line_items": {"data": [{"price": {"id": price_map["credits_25"]}, "quantity": 1}]},
})
ok(f"Missing supplier_id: HTTP {status} (no crash)") if status == 200 else fail(f"HTTP {status}")
time.sleep(1)
balance_after = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
if balance_after == balance_before:
ok("Balance unchanged (correctly skipped — no supplier_id)")
else:
fail(f"Balance changed: {balance_before}{balance_after}")
section("4c. Edge: checkout.session.completed with missing metadata")
status = post_webhook("checkout.session.completed", {
"id": "cs_test_no_meta",
"mode": "payment",
"customer": "cus_test_nometa",
"metadata": {},
})
ok(f"Empty metadata: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
capture_output=True, text=True, timeout=5,
)
ok("Server alive after empty metadata") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
section("4d. Edge: subscription mode checkout (not payment)")
# checkout.session.completed with mode=subscription should create a subscription
status = post_webhook("checkout.session.completed", {
"id": "cs_test_sub_mode",
"mode": "subscription",
"customer": "cus_test_submode",
"subscription": "sub_from_checkout_123",
"metadata": {
"user_id": str(test_user["id"]),
"plan": "starter",
},
})
ok(f"Subscription-mode checkout: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
# Note: this fires subscription.activated, but since we can't mock the Stripe API call
# to fetch the subscription, it will log a warning and continue. That's fine.
section("4e. Edge: sticky boost without sticky_country in metadata")
price_id = price_map.get("boost_sticky_week")
if price_id:
# Reset sticky fields
conn = sqlite3.connect(DATABASE_PATH)
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
conn.commit()
conn.close()
status = post_webhook("checkout.session.completed", {
"id": f"cs_test_no_country_{int(time.time())}",
"mode": "payment",
"customer": "cus_test_nocountry",
"metadata": {
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": "boost_sticky_week",
# NO sticky_country
},
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
})
ok(f"Missing sticky_country: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
time.sleep(2)
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
if sup and sup[0]["sticky_until"]:
ok(f"sticky_until still set (country defaults to empty: '{sup[0]['sticky_country']}')")
else:
fail("sticky boost not created without country")
# ═══════════════════════════════════════════════════════════
# Test 5: Use stripe trigger for a real checkout.session.completed
# ═══════════════════════════════════════════════════════════
section("5. stripe trigger checkout.session.completed (real Stripe event)")
print(" Triggering real checkout.session.completed via Stripe CLI...")
result = subprocess.run(
["stripe", "trigger", "checkout.session.completed"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
ok("stripe trigger succeeded")
# Wait for webhook delivery via ngrok
time.sleep(5)
# Check ngrok for the delivery
import urllib.request
try:
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=5", timeout=5)
reqs = json.loads(resp.read())
recent_webhooks = [
r for r in reqs.get("requests", [])
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
]
if recent_webhooks:
latest = recent_webhooks[0]
http_status = latest.get("response", {}).get("status_code")
ok(f"Webhook delivered via ngrok: HTTP {http_status}")
else:
print(" (no webhook seen in ngrok — may have been delivered before log window)")
ok("stripe trigger completed (webhook delivery not verified)")
except Exception:
ok("stripe trigger completed (ngrok API unavailable for verification)")
else:
fail(f"stripe trigger failed: {result.stderr[:100]}")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
sys.exit(1 if failed else 0)

124
scripts/stripe_e2e_setup.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Step 1: Register a Stripe webhook endpoint via ngrok and update .env.
Run BEFORE starting the dev server:
1. Start ngrok: ngrok http 5000
2. Run this script: uv run python scripts/stripe_e2e_setup.py
3. Start dev server: make dev
4. Run E2E tests: uv run python scripts/stripe_e2e_test.py
To tear down afterward:
uv run python scripts/stripe_e2e_setup.py --teardown
"""
import json
import os
import re
import sys
import urllib.request
from dotenv import load_dotenv
load_dotenv()
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
if not STRIPE_SECRET_KEY:
print("ERROR: Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
ENV_PATH = os.path.join(os.path.dirname(__file__), "..", ".env")
ENV_PATH = os.path.abspath(ENV_PATH)
WEBHOOK_PATH = "/billing/webhook/stripe"
NGROK_API = "http://localhost:4040/api/tunnels"
def _update_env(key, value):
"""Update a key in .env file."""
text = open(ENV_PATH).read()
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, text, re.MULTILINE):
text = re.sub(pattern, replacement, text, flags=re.MULTILINE)
else:
text = text.rstrip("\n") + f"\n{replacement}\n"
open(ENV_PATH, "w").write(text)
def setup():
# Get ngrok tunnel URL
try:
resp = urllib.request.urlopen(NGROK_API, timeout=5)
tunnels = json.loads(resp.read())
tunnel_url = tunnels["tunnels"][0]["public_url"]
except Exception as e:
print(f"ERROR: ngrok not running: {e}")
print("Start ngrok first: ngrok http 5000")
sys.exit(1)
webhook_url = f"{tunnel_url}{WEBHOOK_PATH}"
print(f"ngrok tunnel: {tunnel_url}")
print(f"Webhook URL: {webhook_url}")
# Check for existing E2E webhook endpoint
existing_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
if existing_id:
try:
ep = stripe.WebhookEndpoint.retrieve(existing_id)
if ep.url == webhook_url and ep.status == "enabled":
print(f"\nEndpoint already exists and matches: {existing_id}")
print("Ready to test. Run: uv run python scripts/stripe_e2e_test.py")
return
# URL changed (new ngrok session), delete and recreate
print(f"Existing endpoint URL mismatch, recreating...")
stripe.WebhookEndpoint.delete(existing_id)
except stripe.InvalidRequestError:
pass # Already deleted
# Create webhook endpoint
endpoint = stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=[
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_failed",
],
)
print(f"\nCreated endpoint: {endpoint.id}")
print(f"Webhook secret: {endpoint.secret[:25]}...")
# Update .env
_update_env("STRIPE_WEBHOOK_SECRET", endpoint.secret)
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", endpoint.id)
print("\nUpdated .env with STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID")
print("\nNext steps:")
print(" 1. Restart dev server: make dev")
print(" 2. Run E2E tests: uv run python scripts/stripe_e2e_test.py")
def teardown():
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
if endpoint_id:
try:
stripe.WebhookEndpoint.delete(endpoint_id)
print(f"Deleted webhook endpoint: {endpoint_id}")
except stripe.InvalidRequestError:
print(f"Endpoint {endpoint_id} already deleted")
_update_env("STRIPE_WEBHOOK_SECRET", "")
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", "")
print("Cleared .env webhook config")
if __name__ == "__main__":
if "--teardown" in sys.argv:
teardown()
else:
setup()

727
scripts/stripe_e2e_test.py Normal file
View File

@@ -0,0 +1,727 @@
"""
Comprehensive Stripe E2E Tests — real webhooks via ngrok.
Tests every product type, subscription lifecycle, payment failures,
and edge cases against a running dev server with real Stripe webhooks.
Prerequisites:
1. ngrok http 5000
2. uv run python scripts/stripe_e2e_setup.py
3. make dev (or restart after setup)
4. uv run python scripts/stripe_e2e_test.py
"""
import os
import sqlite3
import subprocess
import sys
import time
from dotenv import load_dotenv
load_dotenv(override=True)
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
assert STRIPE_SECRET_KEY, "Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env"
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
MAX_WAIT_SECONDS = 20
POLL_SECONDS = 0.5
passed = 0
failed = 0
errors = []
cleanup_sub_ids = []
# ─── Helpers ──────────────────────────────────────────────
def ok(msg):
global passed
passed += 1
print(f" \u2713 {msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f" \u2717 {msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
def query_db(sql, params=()):
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
finally:
conn.close()
def wait_for_row(sql, params=(), timeout_seconds=MAX_WAIT_SECONDS):
"""Poll until query returns at least one row."""
deadline = time.time() + timeout_seconds
while time.time() < deadline:
rows = query_db(sql, params)
if rows:
return rows
time.sleep(POLL_SECONDS)
return []
def wait_for_value(sql, params, column, expected, timeout_seconds=MAX_WAIT_SECONDS):
"""Poll until column == expected."""
deadline = time.time() + timeout_seconds
last = None
while time.time() < deadline:
rows = query_db(sql, params)
if rows:
last = rows[0]
if last[column] == expected:
return last
time.sleep(POLL_SECONDS)
return last
def get_or_create_customer(email, name):
existing = stripe.Customer.list(email=email, limit=1)
if existing.data:
return existing.data[0]
return stripe.Customer.create(email=email, name=name, metadata={"e2e": "true"})
_pm_cache = {}
def attach_pm(customer_id):
"""Create a fresh test Visa and attach it."""
if customer_id in _pm_cache:
return _pm_cache[customer_id]
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm.id, customer=customer_id)
stripe.Customer.modify(customer_id, invoice_settings={"default_payment_method": pm.id})
_pm_cache[customer_id] = pm.id
return pm.id
def create_sub(customer_id, price_id, metadata, pm_id):
"""Create subscription and track for cleanup."""
sub = stripe.Subscription.create(
customer=customer_id,
items=[{"price": price_id}],
metadata=metadata,
default_payment_method=pm_id,
)
cleanup_sub_ids.append(sub.id)
return sub
def cancel_sub(sub_id):
try:
stripe.Subscription.cancel(sub_id)
except stripe.InvalidRequestError:
pass
# ─── Preflight ────────────────────────────────────────────
section("Preflight")
# Dev server
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() in ("200", "301", "302"), f"Dev server down (HTTP {result.stdout.strip()})"
ok("Dev server running")
# Webhook endpoint
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
assert endpoint_id, "STRIPE_WEBHOOK_ENDPOINT_ID not set — run stripe_e2e_setup.py"
ep = stripe.WebhookEndpoint.retrieve(endpoint_id)
assert ep.status == "enabled", f"Endpoint status: {ep.status}"
ok(f"Webhook endpoint: {ep.url}")
# Webhook secret loaded in server
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", "{}", "http://localhost:5000/billing/webhook/stripe"],
capture_output=True, text=True, timeout=5,
)
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout.strip()} (need 400 = sig check active)"
ok("Webhook signature verification active")
# Price map
products = query_db("SELECT key, provider_price_id, billing_type FROM payment_products WHERE provider = 'stripe'")
price_map = {p["key"]: p for p in products}
assert len(price_map) >= 17, f"Only {len(price_map)} products"
ok(f"{len(price_map)} Stripe products loaded")
# Test data
users = query_db("SELECT id, email FROM users LIMIT 10")
assert users
test_user = users[0]
ok(f"User: {test_user['email']} (id={test_user['id']})")
suppliers = query_db("SELECT id, name, claimed_by, credit_balance, tier FROM suppliers LIMIT 5")
assert suppliers
# Pick a supplier with claimed_by set (has an owner user)
test_supplier = next((s for s in suppliers if s["claimed_by"]), suppliers[0])
supplier_user_id = test_supplier["claimed_by"] or test_user["id"]
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, owner={supplier_user_id})")
# Record initial supplier state for later comparison
initial_credit_balance = test_supplier["credit_balance"]
# ═══════════════════════════════════════════════════════════
# 1. PLANNER SUBSCRIPTIONS
# ═══════════════════════════════════════════════════════════
section("1a. Planner Starter — create → verify DB → cancel → verify cancelled")
cus_starter = get_or_create_customer("e2e-starter@sandbox.padelnomics.com", "E2E Starter")
pm_starter = attach_pm(cus_starter.id)
sub = create_sub(cus_starter.id, price_map["starter"]["provider_price_id"],
{"user_id": str(test_user["id"]), "plan": "starter"}, pm_starter)
ok(f"Created: {sub.id} (status={sub.status})")
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows:
r = rows[0]
ok(f"DB: plan={r['plan']}, status={r['status']}") if r["plan"] == "starter" and r["status"] == "active" else fail(f"DB: plan={r['plan']}, status={r['status']}")
if r.get("current_period_end"):
ok(f"period_end set: {r['current_period_end'][:10]}")
else:
fail("period_end is NULL")
else:
fail("Subscription NOT in DB")
# billing_customers
bc = query_db("SELECT * FROM billing_customers WHERE user_id = ?", (test_user["id"],))
ok("billing_customers created") if bc else fail("billing_customers NOT created")
# Cancel
cancel_sub(sub.id)
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
(sub.id,), "status", "cancelled")
ok("Status → cancelled") if result and result["status"] == "cancelled" else fail(f"Status: {result['status'] if result else '?'}")
section("1b. Planner Pro — subscription lifecycle")
pro_user = users[1] if len(users) > 1 else users[0]
cus_pro = get_or_create_customer("e2e-pro@sandbox.padelnomics.com", "E2E Pro")
pm_pro = attach_pm(cus_pro.id)
sub = create_sub(cus_pro.id, price_map["pro"]["provider_price_id"],
{"user_id": str(pro_user["id"]), "plan": "pro"}, pm_pro)
ok(f"Created: {sub.id}")
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows and rows[0]["plan"] == "pro" and rows[0]["status"] == "active":
ok("DB: plan=pro, status=active")
else:
fail(f"DB: {rows[0] if rows else 'not found'}")
cancel_sub(sub.id)
ok("Cleaned up")
# ═══════════════════════════════════════════════════════════
# 2. SUPPLIER SUBSCRIPTIONS (all 4 variants)
# ═══════════════════════════════════════════════════════════
section("2a. Supplier Growth (monthly) — tier, credits, verified")
cus_sup = get_or_create_customer("e2e-supplier@sandbox.padelnomics.com", "E2E Supplier")
pm_sup = attach_pm(cus_sup.id)
sub = create_sub(cus_sup.id, price_map["supplier_growth"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_growth",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, is_verified, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "growth",
)
if result:
ok("tier=growth") if result["tier"] == "growth" else fail(f"tier={result['tier']}")
ok("is_verified=1") if result["is_verified"] == 1 else fail(f"is_verified={result['is_verified']}")
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 30 else fail(f"credit_balance={result['credit_balance']}")
else:
fail("Tier not updated")
# Check credit ledger entry was created
ledger = query_db(
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'monthly_allocation' ORDER BY id DESC LIMIT 1",
(test_supplier["id"],),
)
ok("Credit ledger entry created") if ledger else fail("No credit ledger entry")
cancel_sub(sub.id)
ok("Cleaned up")
section("2b. Supplier Pro (monthly) — 100 credits")
# Reset supplier to basic first
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_pro"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_pro",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "pro",
)
if result:
ok("tier=pro") if result["tier"] == "pro" else fail(f"tier={result['tier']}")
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 100 else fail(f"credit_balance={result['credit_balance']}")
else:
fail("Tier not updated to pro")
cancel_sub(sub.id)
ok("Cleaned up")
section("2c. Supplier Growth (yearly)")
# Reset
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_growth_yearly"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_growth_yearly",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "growth",
)
if result:
ok("tier=growth (yearly maps to growth)")
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
else:
fail("Yearly growth not processed")
cancel_sub(sub.id)
ok("Cleaned up")
section("2d. Supplier Pro (yearly)")
query_conn = sqlite3.connect(DATABASE_PATH)
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
(test_supplier["id"],))
query_conn.commit()
query_conn.close()
time.sleep(1)
sub = create_sub(cus_sup.id, price_map["supplier_pro_yearly"]["provider_price_id"], {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": "supplier_pro_yearly",
}, pm_sup)
ok(f"Created: {sub.id}")
result = wait_for_value(
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
(test_supplier["id"],), "tier", "pro",
)
if result:
ok("tier=pro (yearly maps to pro)")
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
else:
fail("Yearly pro not processed")
cancel_sub(sub.id)
ok("Cleaned up")
# ═══════════════════════════════════════════════════════════
# 3. BOOST ADD-ON SUBSCRIPTIONS (all 4)
# ═══════════════════════════════════════════════════════════
section("3. Boost add-on subscriptions (Logo, Highlight, Verified, Card Color)")
cus_boost = get_or_create_customer("e2e-boost@sandbox.padelnomics.com", "E2E Boost")
pm_boost = attach_pm(cus_boost.id)
boost_keys = ["boost_logo", "boost_highlight", "boost_verified", "boost_card_color"]
for key in boost_keys:
price_id = price_map[key]["provider_price_id"]
sub = create_sub(cus_boost.id, price_id, {
"user_id": str(supplier_user_id),
"supplier_id": str(test_supplier["id"]),
"plan": key,
}, pm_boost)
ok(f"{key}: {sub.id} (active)")
# Let webhook arrive
time.sleep(2)
cancel_sub(sub.id)
# Boosts with plan starting "boost_" don't hit supplier handler (only supplier_ plans do).
# They go through the user subscription path. Verify at least the webhooks were accepted.
# Check ngrok logs for 200s
import json
import urllib.request
try:
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=50", timeout=5)
requests_data = json.loads(resp.read())
webhook_200s = sum(1 for r in requests_data.get("requests", [])
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
and r.get("response", {}).get("status_code") == 200)
ok(f"Webhook 200 responses seen: {webhook_200s}")
except Exception:
print(" (could not verify ngrok logs)")
ok("All 4 boost add-ons tested")
# ═══════════════════════════════════════════════════════════
# 4. CHECKOUT SESSIONS — every product
# ═══════════════════════════════════════════════════════════
section("4. Checkout session creation (all 17 products)")
try:
ngrok_resp = urllib.request.urlopen("http://localhost:4040/api/tunnels", timeout=5)
tunnel_url = json.loads(ngrok_resp.read())["tunnels"][0]["public_url"]
except Exception:
tunnel_url = "http://localhost:5000"
checkout_ok = 0
for key, p in sorted(price_map.items()):
mode = "subscription" if p["billing_type"] == "subscription" else "payment"
try:
stripe.checkout.Session.create(
mode=mode,
customer=cus_starter.id,
line_items=[{"price": p["provider_price_id"], "quantity": 1}],
metadata={"user_id": str(test_user["id"]), "plan": key, "test": "true"},
success_url=f"{tunnel_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{tunnel_url}/billing/pricing",
)
checkout_ok += 1
except stripe.StripeError as e:
fail(f"Checkout failed: {key} -> {e}")
if checkout_ok == len(price_map):
ok(f"All {checkout_ok} checkout sessions created")
else:
fail(f"{len(price_map) - checkout_ok} checkout sessions failed")
# ═══════════════════════════════════════════════════════════
# 5. PAYMENT FAILURE — declined card
# ═══════════════════════════════════════════════════════════
section("5. Payment failure — declined card scenarios")
cus_fail = get_or_create_customer("e2e-failure@sandbox.padelnomics.com", "E2E Failure")
fail_user = users[2] if len(users) > 2 else users[0]
# 5a. First create a valid subscription, then simulate payment failure
pm_valid = attach_pm(cus_fail.id)
try:
sub_fail = stripe.Subscription.create(
customer=cus_fail.id,
items=[{"price": price_map["starter"]["provider_price_id"]}],
metadata={"user_id": str(fail_user["id"]), "plan": "starter"},
default_payment_method=pm_valid,
)
cleanup_sub_ids.append(sub_fail.id)
ok(f"Created valid sub first: {sub_fail.id} (status={sub_fail.status})")
# Wait for subscription.created webhook
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub_fail.id,))
ok("DB row created") if rows else fail("No DB row after valid sub creation")
# Now swap to a declined card — next invoice will fail
try:
pm_decline = stripe.PaymentMethod.create(type="card", card={"token": "tok_chargeDeclined"})
stripe.PaymentMethod.attach(pm_decline.id, customer=cus_fail.id)
stripe.Customer.modify(cus_fail.id, invoice_settings={"default_payment_method": pm_decline.id})
ok("Swapped to declined card for next billing cycle")
except stripe.CardError:
ok("tok_chargeDeclined rejected at attach (newer API) — card swap skipped")
cancel_sub(sub_fail.id)
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
(sub_fail.id,), "status", "cancelled")
ok("Cancelled after failure test") if result else ok("Cleanup done")
except stripe.CardError as e:
ok(f"Card declined at subscription level: {e.user_message}")
# 5b. Try creating subscription with payment_behavior=default_incomplete
try:
pm_ok = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm_ok.id, customer=cus_fail.id)
sub_inc = stripe.Subscription.create(
customer=cus_fail.id,
items=[{"price": price_map["pro"]["provider_price_id"]}],
metadata={"user_id": str(fail_user["id"]), "plan": "pro"},
default_payment_method=pm_ok.id,
payment_behavior="default_incomplete",
)
cleanup_sub_ids.append(sub_inc.id)
ok(f"Incomplete-mode sub: {sub_inc.id} (status={sub_inc.status})")
cancel_sub(sub_inc.id)
except stripe.StripeError as e:
ok(f"Incomplete mode handled: {e}")
# ═══════════════════════════════════════════════════════════
# 6. EDGE CASES
# ═══════════════════════════════════════════════════════════
section("6a. Edge case — missing user_id in metadata")
cus_edge = get_or_create_customer("e2e-edge@sandbox.padelnomics.com", "E2E Edge")
pm_edge = attach_pm(cus_edge.id)
sub = create_sub(cus_edge.id, price_map["starter"]["provider_price_id"],
{"plan": "starter"}, # NO user_id
pm_edge)
ok(f"Created sub without user_id: {sub.id}")
# Webhook should arrive but handler should not crash (no DB write expected)
time.sleep(5)
# Server should not have crashed — verify it's still up
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server still alive after missing user_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
cancel_sub(sub.id)
section("6b. Edge case — missing supplier_id for supplier plan")
sub = create_sub(cus_edge.id, price_map["supplier_growth"]["provider_price_id"],
{"user_id": str(test_user["id"]), "plan": "supplier_growth"}, # NO supplier_id
pm_edge)
ok(f"Created supplier sub without supplier_id: {sub.id}")
time.sleep(5)
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server still alive after missing supplier_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
cancel_sub(sub.id)
section("6c. Edge case — duplicate subscription (idempotency)")
# Create same subscription twice for same user
cus_dup = get_or_create_customer("e2e-dup@sandbox.padelnomics.com", "E2E Dup")
pm_dup = attach_pm(cus_dup.id)
dup_user = users[3] if len(users) > 3 else users[0]
sub1 = create_sub(cus_dup.id, price_map["starter"]["provider_price_id"],
{"user_id": str(dup_user["id"]), "plan": "starter"}, pm_dup)
time.sleep(3)
sub2 = create_sub(cus_dup.id, price_map["pro"]["provider_price_id"],
{"user_id": str(dup_user["id"]), "plan": "pro"}, pm_dup)
time.sleep(3)
rows = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at", (dup_user["id"],))
ok(f"Two subscriptions exist: {len(rows)} rows") if len(rows) >= 2 else fail(f"Expected 2+ rows, got {len(rows)}")
# get_subscription returns most recent
latest = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", (dup_user["id"],))
if latest and latest[0]["plan"] == "pro":
ok("Latest subscription is 'pro' (upgrade scenario)")
else:
fail(f"Latest plan: {latest[0]['plan'] if latest else '?'}")
cancel_sub(sub1.id)
cancel_sub(sub2.id)
section("6d. Edge case — rapid create + cancel (race condition)")
cus_race = get_or_create_customer("e2e-race@sandbox.padelnomics.com", "E2E Race")
pm_race = attach_pm(cus_race.id)
race_user = users[4] if len(users) > 4 else users[0]
sub = create_sub(cus_race.id, price_map["starter"]["provider_price_id"],
{"user_id": str(race_user["id"]), "plan": "starter"}, pm_race)
# Cancel immediately — webhooks may arrive out of order
stripe.Subscription.cancel(sub.id)
ok(f"Created and immediately cancelled: {sub.id}")
time.sleep(8) # Wait for both webhooks
rows = query_db("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
if rows:
ok(f"Final DB status: {rows[0]['status']}")
else:
ok("No DB row (created webhook may have arrived after deleted)")
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
capture_output=True, text=True, timeout=5,
)
ok("Server survived race condition") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
# ═══════════════════════════════════════════════════════════
# 7. BILLING PORTAL
# ═══════════════════════════════════════════════════════════
section("7. Billing Portal session")
try:
portal = stripe.billing_portal.Session.create(
customer=cus_starter.id,
return_url=f"{tunnel_url}/billing/success",
)
ok(f"Portal URL: {portal.url[:50]}...")
except stripe.StripeError as e:
fail(f"Portal failed: {e}")
# ═══════════════════════════════════════════════════════════
# 8. ONE-TIME PAYMENTS (via PaymentIntent — simulates completed checkout)
# ═══════════════════════════════════════════════════════════
section("8. One-time payments (PaymentIntents — all credit packs + boosts + PDF)")
cus_buyer = get_or_create_customer("e2e-buyer@sandbox.padelnomics.com", "E2E Buyer")
pm_buyer = attach_pm(cus_buyer.id)
one_time_products = [
("credits_25", 9900),
("credits_50", 17900),
("credits_100", 32900),
("credits_250", 74900),
("boost_sticky_week", 7900),
("boost_sticky_month", 19900),
("business_plan", 14900),
]
for key, amount_cents in one_time_products:
try:
pi = stripe.PaymentIntent.create(
amount=amount_cents,
currency="eur",
customer=cus_buyer.id,
payment_method=pm_buyer,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
"user_id": str(test_user["id"]),
"supplier_id": str(test_supplier["id"]),
"plan": key,
},
)
if pi.status == "succeeded":
ok(f"{key}: \u20ac{amount_cents/100:.2f} succeeded ({pi.id[:20]}...)")
else:
fail(f"{key}: status={pi.status}")
except stripe.StripeError as e:
fail(f"{key}: {e}")
# Note: PaymentIntents don't trigger checkout.session.completed webhooks.
# The actual credit/boost/PDF creation requires a Checkout Session completion,
# which can only happen via browser. These tests verify the payments succeed.
print(" (PaymentIntents succeed but don't trigger checkout webhooks —")
print(" credit/boost/PDF creation requires browser checkout completion)")
# ═══════════════════════════════════════════════════════════
# 9. DECLINED CARDS — different failure modes
# ═══════════════════════════════════════════════════════════
section("9. Declined card scenarios (PaymentIntent level)")
decline_tokens = [
("tok_chargeDeclined", "generic decline"),
("tok_chargeDeclinedInsufficientFunds", "insufficient funds"),
("tok_chargeDeclinedExpiredCard", "expired card"),
("tok_chargeDeclinedProcessingError", "processing error"),
]
for token, description in decline_tokens:
try:
pm = stripe.PaymentMethod.create(type="card", card={"token": token})
stripe.PaymentMethod.attach(pm.id, customer=cus_buyer.id)
pi = stripe.PaymentIntent.create(
amount=1900,
currency="eur",
customer=cus_buyer.id,
payment_method=pm.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
)
fail(f"{description}: should have been declined but succeeded")
except stripe.CardError as e:
ok(f"{description}: correctly declined ({e.code})")
except stripe.StripeError as e:
ok(f"{description}: rejected ({type(e).__name__})")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
# Final cleanup: cancel any remaining subs
for sid in cleanup_sub_ids:
try:
stripe.Subscription.cancel(sid)
except Exception:
pass
sys.exit(1 if failed else 0)

View File

@@ -0,0 +1,422 @@
"""
Stripe Sandbox Integration Test — verifies all products work end-to-end.
Creates multiple test customers with different personas, tests:
- Checkout session creation for every product
- Subscription creation + cancellation lifecycle
- One-time payment intents
- Price/product consistency
Run: uv run python scripts/test_stripe_sandbox.py
"""
import os
import sys
import time
from dotenv import load_dotenv
load_dotenv()
import stripe
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
if not STRIPE_SECRET_KEY:
print("ERROR: STRIPE_SECRET_KEY / STRIPE_API_PRIVATE_KEY not set in .env")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
# ═══════════════════════════════════════════════════════════
# Expected product catalog — must match setup_stripe.py
# ═══════════════════════════════════════════════════════════
EXPECTED_PRODUCTS = {
"Supplier Growth": {"price_cents": 19900, "billing": "subscription", "interval": "month"},
"Supplier Growth (Yearly)": {"price_cents": 179900, "billing": "subscription", "interval": "year"},
"Supplier Pro": {"price_cents": 49900, "billing": "subscription", "interval": "month"},
"Supplier Pro (Yearly)": {"price_cents": 449900, "billing": "subscription", "interval": "year"},
"Boost: Logo": {"price_cents": 2900, "billing": "subscription", "interval": "month"},
"Boost: Highlight": {"price_cents": 3900, "billing": "subscription", "interval": "month"},
"Boost: Verified Badge": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
"Boost: Custom Card Color": {"price_cents": 5900, "billing": "subscription", "interval": "month"},
"Boost: Sticky Top 1 Week": {"price_cents": 7900, "billing": "one_time"},
"Boost: Sticky Top 1 Month": {"price_cents": 19900, "billing": "one_time"},
"Credit Pack 25": {"price_cents": 9900, "billing": "one_time"},
"Credit Pack 50": {"price_cents": 17900, "billing": "one_time"},
"Credit Pack 100": {"price_cents": 32900, "billing": "one_time"},
"Credit Pack 250": {"price_cents": 74900, "billing": "one_time"},
"Padel Business Plan (PDF)": {"price_cents": 14900, "billing": "one_time"},
"Planner Starter": {"price_cents": 1900, "billing": "subscription", "interval": "month"},
"Planner Pro": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
}
# Test customer personas
TEST_CUSTOMERS = [
{"email": "planner-starter@sandbox.padelnomics.com", "name": "Anna Planner (Starter)"},
{"email": "planner-pro@sandbox.padelnomics.com", "name": "Ben Planner (Pro)"},
{"email": "supplier-growth@sandbox.padelnomics.com", "name": "Carlos Supplier (Growth)"},
{"email": "supplier-pro@sandbox.padelnomics.com", "name": "Diana Supplier (Pro)"},
{"email": "one-time-buyer@sandbox.padelnomics.com", "name": "Eva Buyer (Credits+Boosts)"},
]
passed = 0
failed = 0
errors = []
def ok(msg):
global passed
passed += 1
print(f"{msg}")
def fail(msg):
global failed
failed += 1
errors.append(msg)
print(f"{msg}")
def section(title):
print(f"\n{'' * 60}")
print(f" {title}")
print(f"{'' * 60}")
# ═══════════════════════════════════════════════════════════
# Phase 1: Verify all products and prices exist
# ═══════════════════════════════════════════════════════════
section("Phase 1: Product & Price Verification")
products = list(stripe.Product.list(limit=100, active=True).auto_paging_iter())
product_map = {} # name -> {product_id, price_id, price_amount, price_type, interval}
for product in products:
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
continue
price = prices.data[0]
product_map[product.name] = {
"product_id": product.id,
"price_id": price.id,
"price_amount": price.unit_amount,
"price_type": price.type,
"interval": price.recurring.interval if price.recurring else None,
}
for name, expected in EXPECTED_PRODUCTS.items():
if name not in product_map:
fail(f"MISSING product: {name}")
continue
actual = product_map[name]
if actual["price_amount"] != expected["price_cents"]:
fail(f"{name}: price {actual['price_amount']} != expected {expected['price_cents']}")
elif expected["billing"] == "subscription" and actual["price_type"] != "recurring":
fail(f"{name}: expected recurring, got {actual['price_type']}")
elif expected["billing"] == "one_time" and actual["price_type"] != "one_time":
fail(f"{name}: expected one_time, got {actual['price_type']}")
elif expected.get("interval") and actual["interval"] != expected["interval"]:
fail(f"{name}: interval {actual['interval']} != expected {expected['interval']}")
else:
ok(f"{name}: €{actual['price_amount']/100:.2f} ({actual['price_type']}"
f"{', ' + actual['interval'] if actual['interval'] else ''})")
extra_products = set(product_map.keys()) - set(EXPECTED_PRODUCTS.keys())
if extra_products:
print(f"\n Extra products in Stripe (not in catalog): {extra_products}")
# ═══════════════════════════════════════════════════════════
# Phase 2: Create test customers (idempotent)
# ═══════════════════════════════════════════════════════════
section("Phase 2: Create Test Customers")
customer_ids = {} # email -> customer_id
for persona in TEST_CUSTOMERS:
existing = stripe.Customer.list(email=persona["email"], limit=1)
if existing.data:
cus = existing.data[0]
ok(f"Reusing: {persona['name']} ({cus.id})")
else:
cus = stripe.Customer.create(
email=persona["email"],
name=persona["name"],
metadata={"test": "true", "persona": persona["name"]},
)
ok(f"Created: {persona['name']} ({cus.id})")
customer_ids[persona["email"]] = cus.id
# ═══════════════════════════════════════════════════════════
# Phase 3: Test Checkout Sessions for every product
# ═══════════════════════════════════════════════════════════
section("Phase 3: Checkout Session Creation (all products)")
success_url = f"{BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{BASE_URL}/billing/pricing"
# Use the first customer for checkout tests
checkout_customer = customer_ids["planner-starter@sandbox.padelnomics.com"]
for name, info in product_map.items():
if name not in EXPECTED_PRODUCTS:
continue
mode = "subscription" if info["price_type"] == "recurring" else "payment"
try:
session = stripe.checkout.Session.create(
mode=mode,
customer=checkout_customer,
line_items=[{"price": info["price_id"], "quantity": 1}],
metadata={"user_id": "999", "plan": name, "test": "true"},
success_url=success_url,
cancel_url=cancel_url,
)
ok(f"Checkout ({mode}): {name} -> {session.id[:30]}...")
except stripe.StripeError as e:
fail(f"Checkout FAILED for {name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 4: Subscription lifecycle tests (per persona)
# ═══════════════════════════════════════════════════════════
section("Phase 4: Subscription Lifecycle Tests")
created_subs = []
# Cache: customer_id -> payment_method_id
_customer_pms = {}
def _ensure_payment_method(cus_id):
"""Create and attach a test Visa card to a customer (cached)."""
if cus_id in _customer_pms:
return _customer_pms[cus_id]
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
stripe.PaymentMethod.attach(pm.id, customer=cus_id)
stripe.Customer.modify(
cus_id,
invoice_settings={"default_payment_method": pm.id},
)
_customer_pms[cus_id] = pm.id
return pm.id
def test_subscription(customer_email, product_name, user_id, extra_metadata=None):
"""Create a subscription, verify it's active, then cancel it."""
cus_id = customer_ids[customer_email]
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
return
metadata = {"user_id": str(user_id), "plan": product_name, "test": "true"}
if extra_metadata:
metadata.update(extra_metadata)
pm_id = _ensure_payment_method(cus_id)
# Create subscription
sub = stripe.Subscription.create(
customer=cus_id,
items=[{"price": info["price_id"]}],
metadata=metadata,
default_payment_method=pm_id,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Sub created: {product_name} for {customer_email} -> {sub.id} (active)")
else:
fail(f"Sub status unexpected: {product_name} -> {sub.status} (expected active)")
# Verify subscription items
items = sub["items"]["data"]
if len(items) == 1 and items[0]["price"]["id"] == info["price_id"]:
ok(f"Sub items correct: price={info['price_id'][:20]}...")
else:
fail(f"Sub items mismatch for {product_name}")
# Cancel at period end
updated = stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
if updated.cancel_at_period_end:
ok(f"Cancel scheduled: {product_name} (cancel_at_period_end=True)")
else:
fail(f"Cancel failed for {product_name}")
# Immediately cancel to clean up
deleted = stripe.Subscription.cancel(sub.id)
if deleted.status == "canceled":
ok(f"Cancelled: {product_name} -> {deleted.status}")
else:
fail(f"Final cancel status: {product_name} -> {deleted.status}")
# Planner Starter
test_subscription(
"planner-starter@sandbox.padelnomics.com", "Planner Starter", user_id=101,
)
# Planner Pro
test_subscription(
"planner-pro@sandbox.padelnomics.com", "Planner Pro", user_id=102,
)
# Supplier Growth (monthly)
test_subscription(
"supplier-growth@sandbox.padelnomics.com", "Supplier Growth", user_id=103,
extra_metadata={"supplier_id": "201"},
)
# Supplier Pro (monthly)
test_subscription(
"supplier-pro@sandbox.padelnomics.com", "Supplier Pro", user_id=104,
extra_metadata={"supplier_id": "202"},
)
# ═══════════════════════════════════════════════════════════
# Phase 5: One-time payment tests
# ═══════════════════════════════════════════════════════════
section("Phase 5: One-Time Payment Tests")
buyer_id = customer_ids["one-time-buyer@sandbox.padelnomics.com"]
buyer_pm = _ensure_payment_method(buyer_id)
ONE_TIME_PRODUCTS = [
"Credit Pack 25",
"Credit Pack 50",
"Credit Pack 100",
"Credit Pack 250",
"Boost: Sticky Top 1 Week",
"Boost: Sticky Top 1 Month",
"Padel Business Plan (PDF)",
]
for product_name in ONE_TIME_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
pi = stripe.PaymentIntent.create(
amount=info["price_amount"],
currency="eur",
customer=buyer_id,
payment_method=buyer_pm,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
"user_id": "105",
"supplier_id": "203",
"plan": product_name,
"test": "true",
},
)
if pi.status == "succeeded":
ok(f"Payment: {product_name} -> €{info['price_amount']/100:.2f} ({pi.id[:25]}...)")
else:
fail(f"Payment status: {product_name} -> {pi.status}")
except stripe.StripeError as e:
fail(f"Payment FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 6: Boost subscription add-ons
# ═══════════════════════════════════════════════════════════
section("Phase 6: Boost Add-on Subscriptions")
BOOST_PRODUCTS = [
"Boost: Logo",
"Boost: Highlight",
"Boost: Verified Badge",
"Boost: Custom Card Color",
]
boost_customer = customer_ids["supplier-pro@sandbox.padelnomics.com"]
boost_pm = _ensure_payment_method(boost_customer)
for product_name in BOOST_PRODUCTS:
info = product_map.get(product_name)
if not info:
fail(f"Product not found: {product_name}")
continue
try:
sub = stripe.Subscription.create(
customer=boost_customer,
items=[{"price": info["price_id"]}],
metadata={
"user_id": "104",
"supplier_id": "202",
"plan": product_name,
"test": "true",
},
default_payment_method=boost_pm,
)
created_subs.append(sub.id)
if sub.status == "active":
ok(f"Boost sub: {product_name} -> €{info['price_amount']/100:.2f}/mo ({sub.id[:25]}...)")
else:
fail(f"Boost sub status: {product_name} -> {sub.status}")
# Clean up
stripe.Subscription.cancel(sub.id)
except stripe.StripeError as e:
fail(f"Boost sub FAILED for {product_name}: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Phase 7: Billing Portal access
# ═══════════════════════════════════════════════════════════
section("Phase 7: Billing Portal")
try:
portal = stripe.billing_portal.Session.create(
customer=checkout_customer,
return_url=f"{BASE_URL}/billing/success",
)
ok(f"Portal URL generated: {portal.url[:50]}...")
except stripe.StripeError as e:
fail(f"Portal creation failed: {e.user_message or str(e)}")
# ═══════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════
section("RESULTS")
total = passed + failed
print(f"\n {passed}/{total} passed, {failed} failed\n")
if errors:
print(" Failures:")
for err in errors:
print(f" - {err}")
print()
# Customer summary
print(" Test customers in sandbox:")
for persona in TEST_CUSTOMERS:
cid = customer_ids.get(persona["email"], "?")
print(f" {persona['name']}: {cid}")
print()
sys.exit(1 if failed else 0)

View File

@@ -247,7 +247,7 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
def run_transform() -> None:
"""Run SQLMesh — it evaluates model staleness internally."""
"""Run SQLMesh — detects new/modified/deleted models and applies changes."""
logger.info("Running SQLMesh transform")
ok, err = run_shell(
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
@@ -358,6 +358,8 @@ def git_pull_and_sync() -> None:
run_shell(f"git checkout --detach {latest}")
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
run_shell("uv sync --all-packages")
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
# systemd sees it as the same PID and does not restart the unit.
logger.info("Deploy complete — re-execing to load new code")

View File

@@ -54,28 +54,29 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
| Dimension | Grain | Used by |
|-----------|-------|---------|
| `foundation.dim_countries` | `country_code` | `dim_cities`, `dim_locations`, `pseo_city_costs_de`, `planner_defaults` — single source for country names, income, PLI/cost overrides |
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models |
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.location_profiles` (city_slug + city_padel_venue_count) → all pSEO serving models |
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_profiles` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
## Source integration map
```
stg_playtomic_venues ─┐
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score)
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──
stg_padel_courts ─┘ └→ dim_venue_capacity
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
venue_pricing_benchmarks
stg_population ──→ dim_cities ─────────────────────────────┘
stg_income ──→ dim_cities
stg_population_geonames ─┐
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile
stg_tennis_courts ─┤ (Marktpotenzial-Score)
stg_income ──→ dim_cities
stg_population_geonames ─┐ location_profiles
stg_padel_courts ─┤→ dim_locations ────────→ (both scores:
stg_tennis_courts ─┤ Marktreife + Marktpotenzial)
stg_income ─┘
```

View File

@@ -6,6 +6,8 @@ gateways:
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
extensions:
- spatial
- name: h3
repository: community
default_gateway: duckdb

View File

@@ -16,5 +16,107 @@ def padelnomics_glob(evaluator) -> str:
return f"'{landing_dir}/padelnomics/**/*.csv.gz'"
# Add one macro per landing zone subdirectory you create.
# Pattern: def {source}_glob(evaluator) → f"'{landing_dir}/{source}/**/*.csv.gz'"
# ── Country code helpers ─────────────────────────────────────────────────────
# Shared lookup used by dim_cities and dim_locations.
_COUNTRY_NAMES = {
"DE": "Germany", "ES": "Spain", "GB": "United Kingdom",
"FR": "France", "IT": "Italy", "PT": "Portugal",
"AT": "Austria", "CH": "Switzerland", "NL": "Netherlands",
"BE": "Belgium", "SE": "Sweden", "NO": "Norway",
"DK": "Denmark", "FI": "Finland", "US": "United States",
"AR": "Argentina", "MX": "Mexico", "AE": "UAE",
"AU": "Australia", "IE": "Ireland",
}
def _country_case(col: str) -> str:
"""Build a CASE expression mapping ISO 3166-1 alpha-2 → English name."""
whens = "\n ".join(
f"WHEN '{code}' THEN '{name}'" for code, name in _COUNTRY_NAMES.items()
)
return f"CASE {col}\n {whens}\n ELSE {col}\n END"
@macro()
def country_name(evaluator, code_col) -> str:
"""CASE expression: country code → English name.
Usage in SQL: @country_name(vc.country_code) AS country_name_en
"""
return _country_case(str(code_col))
@macro()
def country_slug(evaluator, code_col) -> str:
"""CASE expression: country code → URL-safe slug (lowercased, spaces → dashes).
Usage in SQL: @country_slug(vc.country_code) AS country_slug
"""
return f"LOWER(REGEXP_REPLACE({_country_case(str(code_col))}, '[^a-zA-Z0-9]+', '-'))"
@macro()
def normalize_eurostat_country(evaluator, code_col) -> str:
"""Normalize Eurostat country codes to ISO 3166-1 alpha-2: EL→GR, UK→GB.
Usage in SQL: @normalize_eurostat_country(geo_code) AS country_code
"""
col = str(code_col)
return f"CASE {col} WHEN 'EL' THEN 'GR' WHEN 'UK' THEN 'GB' ELSE {col} END"
@macro()
def normalize_eurostat_nuts(evaluator, code_col) -> str:
"""Normalize NUTS code prefix: EL→GR, UK→GB, preserving the suffix.
Usage in SQL: @normalize_eurostat_nuts(geo_code) AS nuts_code
"""
col = str(code_col)
return (
f"CASE"
f" WHEN {col} LIKE 'EL%' THEN 'GR' || SUBSTR({col}, 3)"
f" WHEN {col} LIKE 'UK%' THEN 'GB' || SUBSTR({col}, 3)"
f" ELSE {col}"
f" END"
)
@macro()
def slugify(evaluator, col) -> str:
"""URL-safe slug: lowercase → ß→ss → strip accents → non-alnum to dashes → trim.
Usage in SQL: @slugify(city) AS city_slug
"""
c = str(col)
return (
f"TRIM(REGEXP_REPLACE("
f"LOWER(STRIP_ACCENTS(REPLACE(LOWER({c}), 'ß', 'ss'))), "
f"'[^a-z0-9]+', '-'"
f"), '-')"
)
@macro()
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets.
Usage in SQL:
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
@infer_country_from_coords(lat, lon)) AS country_code
"""
lat = str(lat_col)
lon = str(lon_col)
return (
f"CASE"
f" WHEN {lat} BETWEEN 47.27 AND 55.06 AND {lon} BETWEEN 5.87 AND 15.04 THEN 'DE'"
f" WHEN {lat} BETWEEN 35.95 AND 43.79 AND {lon} BETWEEN -9.39 AND 4.33 THEN 'ES'"
f" WHEN {lat} BETWEEN 49.90 AND 60.85 AND {lon} BETWEEN -8.62 AND 1.77 THEN 'GB'"
f" WHEN {lat} BETWEEN 41.36 AND 51.09 AND {lon} BETWEEN -5.14 AND 9.56 THEN 'FR'"
f" WHEN {lat} BETWEEN 45.46 AND 47.80 AND {lon} BETWEEN 5.96 AND 10.49 THEN 'CH'"
f" WHEN {lat} BETWEEN 46.37 AND 49.02 AND {lon} BETWEEN 9.53 AND 17.16 THEN 'AT'"
f" WHEN {lat} BETWEEN 36.35 AND 47.09 AND {lon} BETWEEN 6.62 AND 18.51 THEN 'IT'"
f" WHEN {lat} BETWEEN 37.00 AND 42.15 AND {lon} BETWEEN -9.50 AND -6.19 THEN 'PT'"
f" ELSE NULL"
f" END"
)

View File

@@ -2,10 +2,10 @@
-- Built from venue locations (dim_venues) as the primary source — padelnomics
-- tracks cities where padel venues actually exist, not an administrative city list.
--
-- Conformed dimension: used by city_market_profile and all pSEO serving models.
-- Conformed dimension: used by location_profiles and all pSEO serving models.
-- Integrates four sources:
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
-- stg_income → country-level median income (Eurostat)
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
-- stg_city_labels → Eurostat city_code → city_name mapping (EU cities)
-- stg_population → Eurostat city-level population (EU, joined via city code)
-- stg_population_usa → US Census ACS place population
@@ -33,8 +33,7 @@ venue_cities AS (
SELECT
country_code,
city AS city_name,
-- Lowercase before regex so uppercase letters aren't stripped to '-'
LOWER(REGEXP_REPLACE(LOWER(city), '[^a-z0-9]+', '-')) AS city_slug,
@slugify(city) AS city_slug,
COUNT(*) AS padel_venue_count,
AVG(lat) AS centroid_lat,
AVG(lon) AS centroid_lon
@@ -42,12 +41,6 @@ venue_cities AS (
WHERE city IS NOT NULL AND LENGTH(city) > 0
GROUP BY country_code, city
),
-- Latest country income per country
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Eurostat EU population: join city labels (code→name) with population values.
-- QUALIFY keeps only the most recent year per (country, city name).
eurostat_pop AS (
@@ -109,56 +102,9 @@ SELECT
vc.country_code,
vc.city_slug,
vc.city_name,
-- Human-readable country name for pSEO templates and internal linking
CASE vc.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE vc.country_code
END AS country_name_en,
-- URL-safe country slug
LOWER(REGEXP_REPLACE(
CASE vc.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE vc.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
-- Human-readable country name and slug — from dim_countries (single source of truth)
c.country_name_en,
c.country_slug,
vc.centroid_lat AS lat,
vc.centroid_lon AS lon,
-- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.
@@ -180,13 +126,13 @@ SELECT
0
)::INTEGER AS population_year,
vc.padel_venue_count,
ci.median_income_pps,
ci.income_year,
-- GeoNames ID: FK to dim_locations / location_opportunity_profile.
c.median_income_pps,
c.income_year,
-- GeoNames ID: FK to dim_locations / location_profiles.
-- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.)
COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
FROM venue_cities vc
LEFT JOIN country_income ci ON vc.country_code = ci.country_code
LEFT JOIN foundation.dim_countries c ON vc.country_code = c.country_code
-- Eurostat EU population (via city code→name lookup)
LEFT JOIN eurostat_pop ep
ON vc.country_code = ep.country_code

View File

@@ -0,0 +1,285 @@
-- Conformed country dimension — single authoritative source for all country metadata.
--
-- Consolidates data previously duplicated across dim_cities and dim_locations:
-- - country_name_en / country_slug (was: ~50-line CASE blocks in both models)
-- - median_income_pps (was: country_income CTE in both models)
-- - energy prices, labour costs, PLI indices (new — from Eurostat datasets)
-- - cost override columns for the financial calculator
--
-- Used by: dim_cities, dim_locations, pseo_city_costs_de, planner_defaults.
-- Grain: country_code (one row per ISO 3166-1 alpha-2 country code).
-- Kind: FULL — small table (~40 rows), full refresh daily.
--
-- Cost override columns:
-- NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping filters None).
-- For DE (the baseline country) all overrides are NULL to preserve exact DEFAULTS.
-- For countries missing Eurostat data, NULLs propagate naturally.
-- camelCase column aliases match DEFAULTS keys for auto-mapping in content/__init__.py.
--
-- !! DE baseline values sourced from calculator.py DEFAULTS (web/src/padelnomics/planner/calculator.py).
-- !! If DEFAULTS change, the hardcoded baseline values below must be updated to match.
-- !! Search "DE baseline" in this file to find all affected lines.
MODEL (
name foundation.dim_countries,
kind FULL,
cron '@daily',
grain country_code
);
WITH
-- Latest income per country
latest_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Latest electricity price per country (use most recent semi-annual period)
latest_electricity AS (
SELECT country_code, electricity_eur_kwh, ref_period
FROM staging.stg_electricity_prices
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
),
-- Latest gas price per country
latest_gas AS (
SELECT country_code, gas_eur_gj, ref_period
FROM staging.stg_gas_prices
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
),
-- Latest labour cost per country
latest_labour AS (
SELECT country_code, labour_cost_eur_hour, ref_year
FROM staging.stg_labour_costs
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- Latest PLI per (country, category)
latest_pli AS (
SELECT country_code, category, pli, ref_year
FROM staging.stg_price_levels
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code, category ORDER BY ref_year DESC) = 1
),
-- Pivot PLI categories into columns per country
pli_pivoted AS (
SELECT
country_code,
MAX(pli) FILTER (WHERE category = 'construction') AS construction,
MAX(pli) FILTER (WHERE category = 'housing') AS housing,
MAX(pli) FILTER (WHERE category = 'services') AS services,
MAX(pli) FILTER (WHERE category = 'misc') AS misc,
MAX(pli) FILTER (WHERE category = 'government') AS government
FROM latest_pli
GROUP BY country_code
),
-- DE baseline rows for ratio computation
-- NULL-safe: if DE is missing from a source, ratios produce NULL (safe fallthrough).
de_pli AS (
SELECT construction, housing, services, misc, government
FROM pli_pivoted WHERE country_code = 'DE'
),
de_elec AS (
SELECT electricity_eur_kwh FROM latest_electricity WHERE country_code = 'DE'
),
de_gas AS (
SELECT gas_eur_gj FROM latest_gas WHERE country_code = 'DE'
),
-- All distinct country codes from any source
all_countries AS (
SELECT country_code FROM latest_income
UNION
SELECT country_code FROM latest_electricity
UNION
SELECT country_code FROM latest_gas
UNION
SELECT country_code FROM latest_labour
UNION
SELECT country_code FROM pli_pivoted
-- Ensure known padel markets appear even if Eurostat doesn't cover them yet
UNION ALL
SELECT unnest(['DE','ES','GB','FR','IT','PT','AT','CH','NL','BE','SE','NO','DK','FI',
'US','AR','MX','AE','AU','IE']) AS country_code
)
SELECT
ac.country_code,
-- Country name and slug (single definition, replacing duplicated CASE blocks)
CASE ac.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE ac.country_code
END AS country_name_en,
LOWER(REGEXP_REPLACE(
CASE ac.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE ac.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
-- Income data
i.median_income_pps,
i.income_year,
-- Raw energy and labour data (for reference / future staffed-scenario use)
e.electricity_eur_kwh,
g.gas_eur_gj,
la.labour_cost_eur_hour,
-- PLI indices per category (EU27=100)
p.construction AS pli_construction,
p.housing AS pli_housing,
p.services AS pli_services,
p.misc AS pli_misc,
p.government AS pli_government,
-- ── Calculator cost override columns ────────────────────────────────────
-- NULL for DE = fall through to calculator.py DEFAULTS (safe: auto-mapping skips None).
-- Formulas: country_value = DE_default × (country_price / DE_price)
-- or DE_default × (country_PLI / DE_PLI)
--
-- OPEX overrides — energy (direct price ratio)
-- DE baseline: electricity=600, heating=400 (see calculator.py DEFAULTS)
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(600.0 * (e.electricity_eur_kwh / de_e.electricity_eur_kwh), 0)
END AS electricity,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(400.0 * (g.gas_eur_gj / de_g.gas_eur_gj), 0)
END AS heating,
-- OPEX overrides — PLI-scaled (housing category)
-- DE baseline: rentSqm=4, water=125, outdoorRent=400
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(4.0 * (p.housing / de_p.housing), 2)
END AS rent_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(125.0 * (p.housing / de_p.housing), 0)
END AS water,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(400.0 * (p.housing / de_p.housing), 0)
END AS outdoor_rent,
-- OPEX overrides — PLI-scaled (misc category)
-- DE baseline: insurance=300
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.misc / de_p.misc), 0)
END AS insurance,
-- OPEX overrides — PLI-scaled (services category)
-- DE baseline: cleaning=300, maintenance=300, marketing=350
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
END AS cleaning,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
END AS maintenance,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(350.0 * (p.services / de_p.services), 0)
END AS marketing,
-- OPEX overrides — PLI-scaled (government category)
-- DE baseline: propertyTax=250, permitsCompliance=12000
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(250.0 * (p.government / de_p.government), 0)
END AS property_tax,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(12000.0 * (p.government / de_p.government), 0)
END AS permits_compliance,
-- CAPEX overrides — PLI-scaled (construction category)
-- DE baseline: hallCostSqm=500, foundationSqm=150, hvac=100000, electrical=60000,
-- sanitary=80000, parking=50000, fitout=40000, planning=100000,
-- fireProtection=80000, floorPrep=12000, hvacUpgrade=20000,
-- lightingUpgrade=10000, outdoorFoundation=35, outdoorSiteWork=8000,
-- outdoorLighting=4000, outdoorFencing=6000, workingCapital=15000
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(500.0 * (p.construction / de_p.construction), 0)
END AS hall_cost_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(150.0 * (p.construction / de_p.construction), 0)
END AS foundation_sqm,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
END AS hvac,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(60000.0 * (p.construction / de_p.construction), 0)
END AS electrical,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
END AS sanitary,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(50000.0 * (p.construction / de_p.construction), 0)
END AS parking,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(40000.0 * (p.construction / de_p.construction), 0)
END AS fitout,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
END AS planning,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
END AS fire_protection,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(12000.0 * (p.construction / de_p.construction), 0)
END AS floor_prep,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(20000.0 * (p.construction / de_p.construction), 0)
END AS hvac_upgrade,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(10000.0 * (p.construction / de_p.construction), 0)
END AS lighting_upgrade,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(35.0 * (p.construction / de_p.construction), 0)
END AS outdoor_foundation,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(8000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_site_work,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(4000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_lighting,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(6000.0 * (p.construction / de_p.construction), 0)
END AS outdoor_fencing,
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(15000.0 * (p.construction / de_p.construction), 0)
END AS working_capital,
-- CAPEX overrides — PLI-scaled (housing category)
-- DE baseline: landPriceSqm=60
CASE WHEN ac.country_code = 'DE' THEN NULL
ELSE ROUND(60.0 * (p.housing / de_p.housing), 0)
END AS land_price_sqm
FROM (SELECT DISTINCT country_code FROM all_countries WHERE LENGTH(country_code) = 2) ac
LEFT JOIN latest_income i ON ac.country_code = i.country_code
LEFT JOIN latest_electricity e ON ac.country_code = e.country_code
LEFT JOIN latest_gas g ON ac.country_code = g.country_code
LEFT JOIN latest_labour la ON ac.country_code = la.country_code
LEFT JOIN pli_pivoted p ON ac.country_code = p.country_code
CROSS JOIN de_pli de_p
CROSS JOIN de_elec de_e
CROSS JOIN de_gas de_g
-- Enforce grain
QUALIFY ROW_NUMBER() OVER (PARTITION BY ac.country_code ORDER BY ac.country_code) = 1

View File

@@ -6,9 +6,9 @@
-- covers all locations with population ≥ 1K so zero-court Gemeinden score fully.
--
-- Enriched with:
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
-- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join)
-- stg_income_usa → US state-level income (PPS-normalised)
-- stg_income → country-level income (fallback for all countries)
-- stg_padel_courts → padel venue count + nearest court distance (km)
-- stg_tennis_courts → tennis court count within 25km radius
--
@@ -16,7 +16,7 @@
-- 1. EU NUTS-2 regional income (finest; spatial join via ST_Contains)
-- 2. EU NUTS-1 regional income (fallback when NUTS-2 income missing from dataset)
-- 3. US state income (ratio-normalised to PPS scale; see us_income CTE)
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
-- 4. Country-level income (global fallback from dim_countries / ilc_di03)
--
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
@@ -38,7 +38,7 @@ locations AS (
geoname_id,
city_name AS location_name,
-- URL-safe location slug
LOWER(REGEXP_REPLACE(LOWER(city_name), '[^a-z0-9]+', '-')) AS location_slug,
@slugify(city_name) AS location_slug,
country_code,
lat,
lon,
@@ -49,12 +49,6 @@ locations AS (
FROM staging.stg_population_geonames
WHERE lat IS NOT NULL AND lon IS NOT NULL
),
-- Country income (ilc_di03) — global fallback for all countries
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
),
-- ── EU NUTS-2 income via spatial join ──────────────────────────────────────
-- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons.
-- The bounding box pre-filter (bbox_lat/lon_min/max) eliminates most candidates
@@ -214,60 +208,14 @@ tennis_nearby AS (
SELECT
l.geoname_id,
l.country_code,
-- Human-readable country name (consistent with dim_cities)
CASE l.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END AS country_name_en,
-- URL-safe country slug
LOWER(REGEXP_REPLACE(
CASE l.country_code
WHEN 'DE' THEN 'Germany'
WHEN 'ES' THEN 'Spain'
WHEN 'GB' THEN 'United Kingdom'
WHEN 'FR' THEN 'France'
WHEN 'IT' THEN 'Italy'
WHEN 'PT' THEN 'Portugal'
WHEN 'AT' THEN 'Austria'
WHEN 'CH' THEN 'Switzerland'
WHEN 'NL' THEN 'Netherlands'
WHEN 'BE' THEN 'Belgium'
WHEN 'SE' THEN 'Sweden'
WHEN 'NO' THEN 'Norway'
WHEN 'DK' THEN 'Denmark'
WHEN 'FI' THEN 'Finland'
WHEN 'US' THEN 'United States'
WHEN 'AR' THEN 'Argentina'
WHEN 'MX' THEN 'Mexico'
WHEN 'AE' THEN 'UAE'
WHEN 'AU' THEN 'Australia'
WHEN 'IE' THEN 'Ireland'
ELSE l.country_code
END, '[^a-zA-Z0-9]+', '-'
)) AS country_slug,
-- Human-readable country name and slug — from dim_countries (single source of truth)
c.country_name_en,
c.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
h3_latlng_to_cell(l.lat, l.lon, 5) AS h3_cell_res5,
l.admin1_code,
l.admin2_code,
l.population,
@@ -276,12 +224,12 @@ SELECT
COALESCE(
ri.regional_income_pps, -- EU: NUTS-2 (finest) or NUTS-1 (fallback)
us.median_income_pps, -- US: state-level PPS-equivalent
ci.median_income_pps -- Global: country-level from ilc_di03
c.median_income_pps -- Global: country-level from dim_countries / ilc_di03
) AS median_income_pps,
COALESCE(
ri.regional_income_year,
us.income_year,
ci.income_year
c.income_year
) AS income_year,
COALESCE(pl.padel_venue_count, 0)::INTEGER AS padel_venue_count,
-- Venues per 100K residents (NULL if population = 0)
@@ -293,8 +241,8 @@ SELECT
COALESCE(tn.tennis_courts_within_25km, 0)::INTEGER AS tennis_courts_within_25km,
CURRENT_DATE AS refreshed_date
FROM locations l
LEFT JOIN country_income ci ON l.country_code = ci.country_code
LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id
LEFT JOIN foundation.dim_countries c ON l.country_code = c.country_code
LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id
LEFT JOIN us_income us ON l.country_code = 'US'
AND l.admin1_code = us.admin1_code
LEFT JOIN nearest_padel np ON l.geoname_id = np.geoname_id

View File

@@ -99,7 +99,7 @@ SELECT
indoor_court_count,
outdoor_court_count,
-- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks
LOWER(REGEXP_REPLACE(LOWER(COALESCE(city, '')), '[^a-z0-9]+', '-')) AS city_slug,
@slugify(COALESCE(city, '')) AS city_slug,
extracted_date
FROM ranked
QUALIFY ROW_NUMBER() OVER (

View File

@@ -14,7 +14,10 @@
MODEL (
name foundation.fct_availability_slot,
kind FULL,
kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time)
);
@@ -37,7 +40,8 @@ WITH deduped AS (
captured_at_utc DESC
) AS rn
FROM staging.stg_playtomic_availability
WHERE price_amount IS NOT NULL
WHERE snapshot_date BETWEEN @start_ds AND @end_ds
AND price_amount IS NOT NULL
AND price_amount > 0
)
SELECT

View File

@@ -12,7 +12,10 @@
MODEL (
name foundation.fct_daily_availability,
kind FULL,
kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily',
grain (snapshot_date, tenant_id)
);
@@ -37,6 +40,7 @@ WITH slot_agg AS (
MAX(a.price_currency) AS price_currency,
MAX(a.captured_at_utc) AS captured_at_utc
FROM foundation.fct_availability_slot a
WHERE a.snapshot_date BETWEEN @start_ds AND @end_ds
GROUP BY a.snapshot_date, a.tenant_id
)
SELECT

View File

@@ -3,4 +3,4 @@
Analytics-ready views consumed by the web app and programmatic SEO.
Query these from `analytics.py` via DuckDB read-only connection.
Naming convention: `serving.<purpose>` (e.g. `serving.city_market_profile`)
Naming convention: `serving.<purpose>` (e.g. `serving.location_profiles`)

View File

@@ -1,117 +0,0 @@
-- One Big Table: per-city padel market intelligence.
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
--
-- Padelnomics Marktreife-Score v3 (0100):
-- Answers "How mature/established is this padel market?"
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
--
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
-- (min(1, count/5) kills small-town inflation)
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
-- 15 pts addressable market — log-scaled population, ceiling 1M (context only)
-- 10 pts economic context — income PPS normalised to 200 ceiling
-- 10 pts data quality — completeness discount
-- No saturation discount: high density = maturity, not a penalty
MODEL (
name serving.city_market_profile,
kind FULL,
cron '@daily',
grain (country_code, city_slug)
);
WITH base AS (
SELECT
c.country_code,
c.country_name_en,
c.country_slug,
c.city_name,
c.city_slug,
c.lat,
c.lon,
c.population,
c.population_year,
c.padel_venue_count,
c.median_income_pps,
c.income_year,
c.geoname_id,
-- Venue density: padel venues per 100K residents
CASE WHEN c.population > 0
THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2)
ELSE NULL
END AS venues_per_100k,
-- Data confidence: 1.0 if both population and venues are present
CASE
WHEN c.population > 0 AND c.padel_venue_count > 0 THEN 1.0
WHEN c.population > 0 OR c.padel_venue_count > 0 THEN 0.5
ELSE 0.0
END AS data_confidence,
-- Pricing / occupancy from Playtomic (NULL when no availability data)
vpb.median_hourly_rate,
vpb.median_peak_rate,
vpb.median_offpeak_rate,
vpb.median_occupancy_rate,
vpb.median_daily_revenue_per_venue,
vpb.price_currency
FROM foundation.dim_cities c
LEFT JOIN serving.venue_pricing_benchmarks vpb
ON c.country_code = vpb.country_code
AND c.city_slug = vpb.city_slug
WHERE c.padel_venue_count > 0
),
scored AS (
SELECT *,
ROUND(
-- Supply development (40 pts): THE maturity signal.
-- Log-scaled density: LN(density+1)/LN(21) → 20/100k ≈ full marks.
-- Count gate: min(1, count/5) — 1 venue=20%, 5+ venues=100%.
-- Kills small-town inflation (1 court / 5k pop = 20/100k) without hard cutoffs.
40.0 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
* LEAST(1.0, padel_venue_count / 5.0)
-- Demand evidence (25 pts): occupancy when Playtomic data available.
-- Fallback: 40% of density score (avoids double-counting with supply component).
+ 25.0 * CASE
WHEN median_occupancy_rate IS NOT NULL
THEN LEAST(1.0, median_occupancy_rate / 0.65)
ELSE 0.4 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
* LEAST(1.0, padel_venue_count / 5.0)
END
-- Addressable market (15 pts): population as context, not maturity signal.
-- LN(1) = 0 so zero-pop cities score 0 here.
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
-- Economic context (10 pts): country-level income PPS.
-- Flat per country — kept as context modifier, not primary signal.
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
-- Data quality (10 pts): completeness discount.
+ 10.0 * data_confidence
, 1)
AS market_score
FROM base
)
SELECT
s.country_code,
s.country_name_en,
s.country_slug,
s.city_name,
s.city_slug,
s.lat,
s.lon,
s.population,
s.population_year,
s.padel_venue_count,
s.venues_per_100k,
s.data_confidence,
s.market_score,
s.median_income_pps,
s.income_year,
s.median_hourly_rate,
s.median_peak_rate,
s.median_offpeak_rate,
s.median_occupancy_rate,
s.median_daily_revenue_per_venue,
s.price_currency,
s.geoname_id,
CURRENT_DATE AS refreshed_date
FROM scored s
ORDER BY s.market_score DESC

View File

@@ -0,0 +1,26 @@
-- Per-venue lat/lon for the city detail dot map.
-- Joins dim_venues to dim_cities to attach country_slug and city_slug
-- (needed by the /api/markets/<country>/<city>/venues.json endpoint).
-- Only rows with valid coordinates are included.
MODEL (
name serving.city_venue_locations,
kind FULL,
cron '@daily',
grain venue_id
);
SELECT
v.venue_id,
v.name,
v.lat,
v.lon,
v.court_count,
v.indoor_court_count,
v.outdoor_court_count,
v.city_slug,
c.country_slug
FROM foundation.dim_venues v
JOIN foundation.dim_cities c
ON v.country_code = c.country_code AND v.city_slug = c.city_slug
WHERE v.lat IS NOT NULL AND v.lon IS NOT NULL

View File

@@ -1,86 +0,0 @@
-- Per-location padel investment opportunity intelligence.
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
--
-- Padelnomics Marktpotenzial-Score v2 (0100):
-- Answers "Where should I build a padel court?"
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
-- Zero-court locations score highest on supply gap component (white space = opportunity).
--
-- 25 pts addressable market — log-scaled population, ceiling 500K
-- (opportunity peaks in mid-size cities; megacities already served)
-- 20 pts economic power — country income PPS, normalised to 35,000
-- EU PPS values range 18k-37k; /35k gives real spread.
-- DE ≈ 13.2pts, ES ≈ 10.7pts, SE ≈ 14.3pts.
-- Previously /200 caused all countries to saturate at 20/20.
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks.
-- Ceiling raised to 8/100K (was 4) for a gentler gradient
-- and to account for ~87% data undercount vs FIP totals.
-- Linear: GREATEST(0, 1 - density/8)
-- 15 pts catchment gap — distance to nearest padel court.
-- DuckDB LEAST ignores NULLs: LEAST(1.0, NULL/30) = 1.0,
-- so NULL nearest_km = full marks (no court in bounding box
-- = high opportunity). COALESCE fallback is dead code.
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks).
-- NOTE: dim_locations tennis data is empty (all 0 rows).
-- Component contributes 0 pts everywhere until data lands.
MODEL (
name serving.location_opportunity_profile,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
SELECT
l.geoname_id,
l.country_code,
l.country_name_en,
l.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.population_year,
l.median_income_pps,
l.income_year,
l.padel_venue_count,
l.padel_venues_per_100k,
l.nearest_padel_court_km,
l.tennis_courts_within_25km,
ROUND(
-- Addressable market (25 pts): log-scaled to 500K ceiling.
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
-- that can support a court but aren't already saturated by large-city operators.
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
-- Economic power (20 pts): country-level income PPS normalised to 35,000.
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
-- EU PPS values range 18k-37k; ceiling 35k gives meaningful spread.
-- v1 used /200 which caused LEAST(1.0, 115) = 1.0 for ALL countries (flat, no differentiation).
-- v2: /35000 → DE 0.66×20=13.2pts, ES 0.53×20=10.7pts, SE 0.71×20=14.3pts.
-- Default 15000 for missing data = reasonable developing-market assumption (~0.43).
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 15000) / 35000.0)
-- Supply gap (30 pts): INVERTED venue density.
-- 0 courts/100K = full 30 pts (white space); ≥8/100K = 0 pts (served market).
-- Ceiling raised from 4→8/100K for a gentler gradient and to account for data
-- undercount (~87% of real courts not in our data).
-- This is the key signal that separates Marktpotenzial from Marktreife.
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 8.0)
-- Catchment gap (15 pts): distance to nearest existing padel court.
-- >30km = full 15 pts (underserved catchment area).
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
-- Sports culture proxy (10 pts): tennis courts within 25km.
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
, 1) AS opportunity_score,
CURRENT_DATE AS refreshed_date
FROM foundation.dim_locations l
ORDER BY opportunity_score DESC

View File

@@ -0,0 +1,243 @@
-- Unified location profile: both scores at (country_code, geoname_id) grain.
-- Base: dim_locations (ALL GeoNames locations, pop ≥ 1K, ~140K rows).
-- Enriched with dim_cities (city_slug, city_name, exact venue count) and
-- venue_pricing_benchmarks (Playtomic pricing/occupancy).
--
-- Two scores per location:
--
-- Padelnomics Market Score (Marktreife-Score v3, 0100):
-- "How mature/established is this padel market?"
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
-- with padel venues. 0 for all other locations.
--
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
-- 15 pts addressable market — log-scaled population, ceiling 1M
-- 10 pts economic context — income PPS normalised to 200 ceiling
-- 10 pts data quality — completeness discount
--
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0100):
-- "Where should I build a padel court?"
-- Computed for ALL locations — zero-court locations score highest on supply gap.
-- H3 catchment methodology: addressable market and supply gap use a regional
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
--
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
-- 20 pts economic power — income PPS, normalised to 35,000
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
-- 15 pts catchment gap — distance to nearest padel court
-- 10 pts sports culture — tennis courts within 25km
--
-- Consumers query directly with WHERE filters:
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
-- opportunity API: WHERE country_slug = ? AND opportunity_score > 0
-- planner_defaults: WHERE city_slug IS NOT NULL
-- pseo_*: WHERE city_slug IS NOT NULL AND city_padel_venue_count > 0
MODEL (
name serving.location_profiles,
kind FULL,
cron '@daily',
grain (country_code, geoname_id)
);
WITH
-- All locations from dim_locations (superset)
base AS (
SELECT
l.geoname_id,
l.country_code,
l.country_name_en,
l.country_slug,
l.location_name,
l.location_slug,
l.lat,
l.lon,
l.admin1_code,
l.admin2_code,
l.population,
l.population_year,
l.median_income_pps,
l.income_year,
l.padel_venue_count,
l.padel_venues_per_100k,
l.nearest_padel_court_km,
l.tennis_courts_within_25km,
l.h3_cell_res5
FROM foundation.dim_locations l
),
-- Aggregate population and court counts per H3 cell (res 5, ~8.5km edge).
-- Grouping by cell first (~50-80K distinct cells vs 140K locations) keeps the
-- subsequent lateral join small.
hex_stats AS (
SELECT
h3_cell_res5,
SUM(population) AS hex_population,
SUM(padel_venue_count) AS hex_padel_courts
FROM foundation.dim_locations
GROUP BY h3_cell_res5
),
-- For each location, sum hex_stats across the cell + 6 neighbours (k_ring=1).
-- Effective catchment: ~24km radius — realistic driving distance.
catchment AS (
SELECT
l.geoname_id,
SUM(hs.hex_population) AS catchment_population,
SUM(hs.hex_padel_courts) AS catchment_padel_courts
FROM base l,
LATERAL (SELECT UNNEST(h3_grid_disk(l.h3_cell_res5, 1)) AS cell) ring
JOIN hex_stats hs ON hs.h3_cell_res5 = ring.cell
GROUP BY l.geoname_id
),
-- Match dim_cities via (country_code, geoname_id) to get city_slug + exact venue count.
-- QUALIFY handles rare multi-city-per-geoname collisions (keep highest venue count).
city_match AS (
SELECT
c.country_code,
c.geoname_id,
c.city_slug,
c.city_name,
c.padel_venue_count AS city_padel_venue_count
FROM foundation.dim_cities c
WHERE c.geoname_id IS NOT NULL
QUALIFY ROW_NUMBER() OVER (
PARTITION BY c.country_code, c.geoname_id
ORDER BY c.padel_venue_count DESC
) = 1
),
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
with_pricing AS (
SELECT
b.*,
cm.city_slug,
cm.city_name,
cm.city_padel_venue_count,
vpb.median_hourly_rate,
vpb.median_peak_rate,
vpb.median_offpeak_rate,
vpb.median_occupancy_rate,
vpb.median_daily_revenue_per_venue,
vpb.price_currency,
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
FROM base b
LEFT JOIN city_match cm
ON b.country_code = cm.country_code
AND b.geoname_id = cm.geoname_id
LEFT JOIN serving.venue_pricing_benchmarks vpb
ON cm.country_code = vpb.country_code
AND cm.city_slug = vpb.city_slug
LEFT JOIN catchment ct
ON b.geoname_id = ct.geoname_id
),
-- Both scores computed from the enriched base
scored AS (
SELECT *,
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
CASE WHEN population > 0
THEN ROUND(COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000, 2)
ELSE NULL
END AS city_venues_per_100k,
-- Data confidence (for market_score)
CASE
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
ELSE 0.0
END AS data_confidence,
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
ROUND(
-- Supply development (40 pts)
40.0 * LEAST(1.0, LN(
COALESCE(
CASE WHEN population > 0
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
ELSE 0 END
, 0) + 1) / LN(21))
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
-- Demand evidence (25 pts)
+ 25.0 * CASE
WHEN median_occupancy_rate IS NOT NULL
THEN LEAST(1.0, median_occupancy_rate / 0.65)
ELSE 0.4 * LEAST(1.0, LN(
COALESCE(
CASE WHEN population > 0
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
ELSE 0 END
, 0) + 1) / LN(21))
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
END
-- Addressable market (15 pts)
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
-- Economic context (10 pts)
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
-- Data quality (10 pts)
+ 10.0 * CASE
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
ELSE 0.0
END
, 1)
ELSE 0
END AS market_score,
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
ROUND(
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
-- Economic power (20 pts): income PPS normalised to 35,000
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
-- Supply gap (30 pts): inverted catchment venue density
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(
CASE WHEN catchment_population > 0
THEN catchment_padel_courts::DOUBLE / catchment_population * 100000
ELSE 0.0
END, 0.0) / 8.0)
-- Catchment gap (15 pts): distance to nearest court
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
-- Sports culture (10 pts): tennis courts within 25km
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
, 1) AS opportunity_score
FROM with_pricing
)
SELECT
s.geoname_id,
s.country_code,
s.country_name_en,
s.country_slug,
s.location_name,
s.location_slug,
s.city_slug,
s.city_name,
s.lat,
s.lon,
s.admin1_code,
s.admin2_code,
s.population,
s.population_year,
s.median_income_pps,
s.income_year,
s.padel_venue_count,
s.padel_venues_per_100k,
s.nearest_padel_court_km,
s.tennis_courts_within_25km,
s.city_padel_venue_count,
s.city_venues_per_100k,
s.data_confidence,
s.catchment_population,
s.catchment_padel_courts,
CASE WHEN s.catchment_population > 0
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
ELSE NULL
END AS catchment_venues_per_100k,
s.market_score,
s.opportunity_score,
s.median_hourly_rate,
s.median_peak_rate,
s.median_offpeak_rate,
s.median_occupancy_rate,
s.median_daily_revenue_per_venue,
s.price_currency,
CURRENT_DATE AS refreshed_date
FROM scored s
ORDER BY s.market_score DESC, s.opportunity_score DESC

View File

@@ -7,6 +7,10 @@
-- 2. Country-level: median across cities in same country
-- 3. Hardcoded fallback: market research estimates (only when no Playtomic data)
--
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices) are
-- included so the planner API pre-fills country-adjusted CAPEX/OPEX for all cities.
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
--
-- Units are explicit in column names. Monetary values in local currency.
MODEL (
@@ -72,11 +76,12 @@ city_profiles AS (
city_slug,
country_code,
city_name,
padel_venue_count,
city_padel_venue_count AS padel_venue_count,
population,
market_score,
venues_per_100k
FROM serving.city_market_profile
city_venues_per_100k AS venues_per_100k
FROM serving.location_profiles
WHERE city_slug IS NOT NULL
)
SELECT
cp.city_slug,
@@ -125,6 +130,37 @@ SELECT
ELSE 0.2
END AS data_confidence,
COALESCE(cb.price_currency, ctb.price_currency, hf.currency, 'EUR') AS price_currency,
-- Cost override columns (Eurostat PLI + energy prices via dim_countries).
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline).
dc.electricity,
dc.heating,
dc.rent_sqm,
dc.insurance,
dc.cleaning,
dc.maintenance,
dc.marketing,
dc.water,
dc.property_tax,
dc.outdoor_rent,
dc.hall_cost_sqm,
dc.foundation_sqm,
dc.land_price_sqm,
dc.hvac,
dc.electrical,
dc.sanitary,
dc.parking,
dc.fitout,
dc.planning,
dc.fire_protection,
dc.floor_prep,
dc.hvac_upgrade,
dc.lighting_upgrade,
dc.outdoor_foundation,
dc.outdoor_site_work,
dc.outdoor_lighting,
dc.outdoor_fencing,
dc.working_capital,
dc.permits_compliance,
CURRENT_DATE AS refreshed_date
FROM city_profiles cp
LEFT JOIN city_benchmarks cb
@@ -134,3 +170,5 @@ LEFT JOIN country_benchmarks ctb
ON cp.country_code = ctb.country_code
LEFT JOIN hardcoded_fallbacks hf
ON cp.country_code = hf.country_code
LEFT JOIN foundation.dim_countries dc
ON cp.country_code = dc.country_code

View File

@@ -4,6 +4,10 @@
--
-- Calculator override columns use camelCase to match the DEFAULTS keys in
-- planner/calculator.py, so they are auto-applied as calc pre-fills.
--
-- Cost override columns come from foundation.dim_countries (Eurostat PLI and energy
-- price indices). NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping
-- filters None). DE always produces NULL overrides — preserves exact DEFAULTS behaviour.
MODEL (
name serving.pseo_city_costs_de,
@@ -22,12 +26,15 @@ SELECT
c.country_code,
c.country_name_en,
c.country_slug,
-- City coordinates (for the city venue dot map)
c.lat,
c.lon,
-- Market metrics
c.population,
c.padel_venue_count,
c.venues_per_100k,
c.city_padel_venue_count AS padel_venue_count,
c.city_venues_per_100k AS venues_per_100k,
c.market_score,
lop.opportunity_score,
c.opportunity_score,
c.data_confidence,
-- Pricing (from Playtomic, NULL when no coverage)
c.median_hourly_rate,
@@ -44,14 +51,47 @@ SELECT
FLOOR(p.courts_typical) AS "dblCourts",
-- 'country' drives currency formatting in the calculator
c.country_code AS "country",
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices).
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
-- OPEX overrides
cc.electricity AS "electricity",
cc.heating AS "heating",
cc.rent_sqm AS "rentSqm",
cc.insurance AS "insurance",
cc.cleaning AS "cleaning",
cc.maintenance AS "maintenance",
cc.marketing AS "marketing",
cc.water AS "water",
cc.property_tax AS "propertyTax",
cc.outdoor_rent AS "outdoorRent",
-- CAPEX overrides
cc.hall_cost_sqm AS "hallCostSqm",
cc.foundation_sqm AS "foundationSqm",
cc.land_price_sqm AS "landPriceSqm",
cc.hvac AS "hvac",
cc.electrical AS "electrical",
cc.sanitary AS "sanitary",
cc.parking AS "parking",
cc.fitout AS "fitout",
cc.planning AS "planning",
cc.fire_protection AS "fireProtection",
cc.floor_prep AS "floorPrep",
cc.hvac_upgrade AS "hvacUpgrade",
cc.lighting_upgrade AS "lightingUpgrade",
cc.outdoor_foundation AS "outdoorFoundation",
cc.outdoor_site_work AS "outdoorSiteWork",
cc.outdoor_lighting AS "outdoorLighting",
cc.outdoor_fencing AS "outdoorFencing",
cc.working_capital AS "workingCapital",
cc.permits_compliance AS "permitsCompliance",
CURRENT_DATE AS refreshed_date
FROM serving.city_market_profile c
FROM serving.location_profiles c
LEFT JOIN serving.planner_defaults p
ON c.country_code = p.country_code
AND c.city_slug = p.city_slug
LEFT JOIN serving.location_opportunity_profile lop
ON c.country_code = lop.country_code
AND c.geoname_id = lop.geoname_id
LEFT JOIN foundation.dim_countries cc
ON c.country_code = cc.country_code
-- Only cities with actual padel presence and at least some rate data
WHERE c.padel_venue_count > 0
WHERE c.city_slug IS NOT NULL
AND c.city_padel_venue_count > 0
AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)

View File

@@ -1,6 +1,6 @@
-- pSEO article data: per-city padel court pricing.
-- One row per city — consumed by the city-pricing.md.jinja template.
-- Joins venue_pricing_benchmarks (real Playtomic data) with city_market_profile
-- Joins venue_pricing_benchmarks (real Playtomic data) with location_profiles
-- (population, venue count, country metadata).
--
-- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real
@@ -16,7 +16,7 @@ MODEL (
SELECT
-- Composite natural key: country_slug + city_slug ensures uniqueness across countries
c.country_slug || '-' || c.city_slug AS city_key,
-- City identity (from city_market_profile, which has the canonical city_slug)
-- City identity (from location_profiles, which has the canonical city_slug)
c.city_slug,
c.city_name,
c.country_code,
@@ -24,8 +24,8 @@ SELECT
c.country_slug,
-- Market context
c.population,
c.padel_venue_count,
c.venues_per_100k,
c.city_padel_venue_count AS padel_venue_count,
c.city_venues_per_100k AS venues_per_100k,
c.market_score,
-- Pricing benchmarks (from Playtomic availability data)
vpb.median_hourly_rate,
@@ -38,9 +38,10 @@ SELECT
vpb.price_currency,
CURRENT_DATE AS refreshed_date
FROM serving.venue_pricing_benchmarks vpb
-- Join city_market_profile to get the canonical city_slug and country metadata
INNER JOIN serving.city_market_profile c
-- Join location_profiles to get canonical city metadata
INNER JOIN serving.location_profiles c
ON vpb.country_code = c.country_code
AND vpb.city_slug = c.city_slug
AND c.city_slug IS NOT NULL
-- Only cities with enough venues for meaningful pricing statistics
WHERE vpb.venue_count >= 2

View File

@@ -20,15 +20,15 @@ SELECT
SUM(padel_venue_count) AS total_venues,
ROUND(AVG(market_score), 1) AS avg_market_score,
MAX(market_score) AS top_city_market_score,
-- Top 5 cities by market score for internal linking (DuckDB list slice syntax)
LIST(city_slug ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
LIST(city_name ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_names,
-- Top 5 cities by venue count (prominence), then score for internal linking
LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
MAX(opportunity_score) AS top_opportunity_score,
-- Top 5 cities by opportunity score (may differ from top market score cities)
LIST(city_slug ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
LIST(city_name ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
-- Top 5 opportunity cities by population (prominence), then opportunity score
LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
LIST(city_name ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
-- Pricing medians across cities (NULL when no Playtomic coverage in country)
ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate,
ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,

View File

@@ -27,7 +27,7 @@ WITH venue_stats AS (
MAX(da.active_court_count) AS court_count,
COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
AND da.occupancy_rate IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency

View File

@@ -0,0 +1,42 @@
-- Electricity prices for non-household consumers (Eurostat nrg_pc_205).
-- EUR/kWh excluding taxes, band MWH500-1999 (medium-sized commercial consumer).
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
--
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_205.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 0.1523}, ...]}
MODEL (
name staging.stg_electricity_prices,
kind FULL,
cron '@daily',
grain (country_code, ref_period)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/nrg_pc_205.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRIM(r.ref_year) AS ref_period,
TRY_CAST(r.value AS DOUBLE) AS electricity_eur_kwh
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_period,
electricity_eur_kwh
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND electricity_eur_kwh > 0

View File

@@ -0,0 +1,42 @@
-- Gas prices for non-household consumers (Eurostat nrg_pc_203).
-- EUR/GJ excluding taxes, band GJ1000-9999 (medium-sized commercial consumer).
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
--
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_203.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 14.23}, ...]}
MODEL (
name staging.stg_gas_prices,
kind FULL,
cron '@daily',
grain (country_code, ref_period)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/nrg_pc_203.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRIM(r.ref_year) AS ref_period,
TRY_CAST(r.value AS DOUBLE) AS gas_eur_gj
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_period,
gas_eur_gj
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND gas_eur_gj > 0

View File

@@ -30,11 +30,7 @@ parsed AS (
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
@normalize_eurostat_country(geo_code) AS country_code,
ref_year,
median_income_pps,
extracted_date

View File

@@ -0,0 +1,46 @@
-- Labour cost levels EUR/hour (Eurostat lc_lci_lev).
-- NACE R2 sector N (administrative and support service activities).
-- D1_D2_A_HW structure: wages + non-wage costs, actual hours worked.
-- Annual frequency.
--
-- Stored for future "staffed scenario" calculator variant.
-- Not wired into default calculator overrides (staff=0 is a business assumption).
--
-- Source: data/landing/eurostat/{year}/{month}/lc_lci_lev.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 28.4}, ...]}
MODEL (
name staging.stg_labour_costs,
kind FULL,
cron '@daily',
grain (country_code, ref_year)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/lc_lci_lev.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
TRY_CAST(r.value AS DOUBLE) AS labour_cost_eur_hour
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_year,
labour_cost_eur_hour
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND labour_cost_eur_hour > 0

View File

@@ -28,11 +28,7 @@ WITH raw AS (
SELECT
NUTS_ID AS nuts2_code,
-- Normalise country prefix to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE CNTR_CODE
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE CNTR_CODE
END AS country_code,
@normalize_eurostat_country(CNTR_CODE) AS country_code,
NAME_LATN AS region_name,
geom AS geometry,
-- Pre-compute bounding box for efficient spatial pre-filter in dim_locations.

View File

@@ -48,17 +48,8 @@ deduped AS (
with_country AS (
SELECT
osm_id, lat, lon,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE'
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
ELSE NULL
END) AS country_code,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
@infer_country_from_coords(lat, lon)) AS country_code,
NULLIF(TRIM(name), '') AS name,
NULLIF(TRIM(city_tag), '') AS city,
postcode, operator_name, opening_hours, fee, extracted_date

View File

@@ -13,44 +13,28 @@
MODEL (
name staging.stg_playtomic_availability,
kind FULL,
kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
);
WITH
morning_jsonl AS (
all_jsonl AS (
SELECT
date AS snapshot_date,
CAST(date AS DATE) AS snapshot_date,
captured_at_utc,
'morning' AS snapshot_type,
NULL::INTEGER AS recheck_hour,
tenant_id,
slots AS slots_json
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
format = 'newline_delimited',
columns = {
date: 'VARCHAR',
captured_at_utc: 'VARCHAR',
tenant_id: 'VARCHAR',
slots: 'JSON'
},
filename = true
)
WHERE filename NOT LIKE '%_recheck_%'
AND tenant_id IS NOT NULL
),
recheck_jsonl AS (
SELECT
date AS snapshot_date,
captured_at_utc,
'recheck' AS snapshot_type,
CASE
WHEN filename LIKE '%_recheck_%' THEN 'recheck'
ELSE 'morning'
END AS snapshot_type,
TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour,
tenant_id,
slots AS slots_json
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.jsonl.gz',
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
format = 'newline_delimited',
columns = {
date: 'VARCHAR',
@@ -63,11 +47,6 @@ recheck_jsonl AS (
)
WHERE tenant_id IS NOT NULL
),
all_venues AS (
SELECT * FROM morning_jsonl
UNION ALL
SELECT * FROM recheck_jsonl
),
raw_resources AS (
SELECT
av.snapshot_date,
@@ -76,7 +55,7 @@ raw_resources AS (
av.recheck_hour,
av.tenant_id,
resource_json
FROM all_venues av,
FROM all_jsonl av,
LATERAL UNNEST(
from_json(av.slots_json, '["JSON"]')
) AS t(resource_json)

View File

@@ -0,0 +1,96 @@
-- Price level indices relative to EU27=100 (Eurostat prc_ppp_ind).
-- Five categories, each from a separate landing file (different ppp_cat filters).
-- Annual frequency.
--
-- Categories and what they scale in the calculator:
-- construction — CAPEX: hallCostSqm, foundationSqm, hvac, electrical, sanitary, etc.
-- housing — rentSqm, landPriceSqm, water, outdoorRent
-- services — cleaning, maintenance, marketing
-- misc — insurance
-- government — permitsCompliance, propertyTax
--
-- Sources:
-- data/landing/eurostat/*/*/prc_ppp_ind_construction.json.gz (ppp_cat: A050202)
-- data/landing/eurostat/*/*/prc_ppp_ind_housing.json.gz (ppp_cat: A0104)
-- data/landing/eurostat/*/*/prc_ppp_ind_services.json.gz (ppp_cat: P0201)
-- data/landing/eurostat/*/*/prc_ppp_ind_misc.json.gz (ppp_cat: A0112)
-- data/landing/eurostat/*/*/prc_ppp_ind_government.json.gz (ppp_cat: P0202)
--
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 107.3}, ...]}
MODEL (
name staging.stg_price_levels,
kind FULL,
cron '@daily',
grain (country_code, category, ref_year)
);
WITH construction_raw AS (
SELECT unnest(rows) AS r, 'construction' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_construction.json.gz',
auto_detect = true
)
),
housing_raw AS (
SELECT unnest(rows) AS r, 'housing' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_housing.json.gz',
auto_detect = true
)
),
services_raw AS (
SELECT unnest(rows) AS r, 'services' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_services.json.gz',
auto_detect = true
)
),
misc_raw AS (
SELECT unnest(rows) AS r, 'misc' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_misc.json.gz',
auto_detect = true
)
),
government_raw AS (
SELECT unnest(rows) AS r, 'government' AS category
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_government.json.gz',
auto_detect = true
)
),
all_raw AS (
SELECT r, category FROM construction_raw
UNION ALL
SELECT r, category FROM housing_raw
UNION ALL
SELECT r, category FROM services_raw
UNION ALL
SELECT r, category FROM misc_raw
UNION ALL
SELECT r, category FROM government_raw
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
TRY_CAST(r.value AS DOUBLE) AS pli,
category
FROM all_raw
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
category,
ref_year,
pli
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
AND pli > 0

View File

@@ -30,11 +30,7 @@ parsed AS (
)
SELECT
-- Normalise to ISO 3166-1 alpha-2 prefix: EL→GR, UK→GB
CASE
WHEN geo_code LIKE 'EL%' THEN 'GR' || SUBSTR(geo_code, 3)
WHEN geo_code LIKE 'UK%' THEN 'GB' || SUBSTR(geo_code, 3)
ELSE geo_code
END AS nuts_code,
@normalize_eurostat_nuts(geo_code) AS nuts_code,
-- NUTS level: 3-char = NUTS-1, 4-char = NUTS-2
LENGTH(geo_code) - 2 AS nuts_level,
ref_year,

View File

@@ -54,17 +54,8 @@ deduped AS (
with_country AS (
SELECT
osm_id, lat, lon,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE'
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
ELSE NULL
END) AS country_code,
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
@infer_country_from_coords(lat, lon)) AS country_code,
NULLIF(TRIM(name), '') AS name,
NULLIF(TRIM(city_tag), '') AS city,
extracted_date

20
uv.lock generated
View File

@@ -150,6 +150,11 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" },
{ url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" },
{ url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" },
{ url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" },
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
@@ -1392,6 +1397,7 @@ dependencies = [
{ name = "pyyaml" },
{ name = "quart" },
{ name = "resend" },
{ name = "stripe" },
{ name = "weasyprint" },
]
@@ -1413,6 +1419,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "quart", specifier = ">=0.19.0" },
{ name = "resend", specifier = ">=2.22.0" },
{ name = "stripe", specifier = ">=14.4.0" },
{ name = "weasyprint", specifier = ">=68.1" },
]
@@ -2519,6 +2526,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
[[package]]
name = "stripe"
version = "14.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/ec/0f17cff3f7c91b0215266959c5a2a96b0bf9f45ac041c50b99ad8f9b5047/stripe-14.4.0.tar.gz", hash = "sha256:ddaa06f5e38a582bef7e93e06fc304ba8ae3b4c0c2aac43da02c84926f05fa0a", size = 1472370, upload-time = "2026-02-25T17:52:40.905Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/09/fcecad01d76dbe027015dd559ec1b6dccfc319c2540991dde4b1de81ba34/stripe-14.4.0-py3-none-any.whl", hash = "sha256:357151a816cd0bb012d6cb29f108fae50b9f6eece8530d7bc31dfa90c9ceb84c", size = 2115405, upload-time = "2026-02-25T17:52:39.128Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"

View File

@@ -22,6 +22,7 @@ dependencies = [
"httpx>=0.27.0",
"google-api-python-client>=2.100.0",
"google-auth>=2.23.0",
"stripe>=14.4.0",
]
[build-system]

View File

@@ -48,7 +48,7 @@ PADDLE_ENVIRONMENT=${PADDLE_ENVIRONMENT:-sandbox}
# -- Preparation -------------------------------------------------------------
info "Resetting database"
rm -f "$DATABASE_PATH"
rm -f "$DATABASE_PATH" "${DATABASE_PATH}-shm" "${DATABASE_PATH}-wal"
ok "Removed $DATABASE_PATH"
info "Running migrations"
@@ -165,7 +165,7 @@ echo ""
echo "Press Ctrl-C to stop all processes."
echo ""
run_with_label "$COLOR_APP" "app " uv run granian --interface asgi --host 127.0.0.1 --port 5000 --reload --reload-paths web/src padelnomics.app:app
run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
run_with_label "$COLOR_CSS" "css " make css-watch

View File

@@ -35,7 +35,7 @@ from pathlib import Path
from quart import Blueprint, flash, redirect, render_template, request, url_for
from ..auth.routes import role_required
from ..core import csrf_protect
from ..core import count_where, csrf_protect
logger = logging.getLogger(__name__)
@@ -51,8 +51,10 @@ bp = Blueprint(
_LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
# Repo root: web/src/padelnomics/admin/ → up 4 levels
_REPO_ROOT = Path(__file__).resolve().parents[4]
# In prod the package is installed in a venv so __file__.parents[4] won't
# reach the repo checkout. WorkingDirectory in the systemd unit is /opt/padelnomics,
# so CWD is reliable; REPO_ROOT env var overrides for non-standard setups.
_REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
# A "running" row older than this is considered stale/crashed.
@@ -109,13 +111,12 @@ _DAG: dict[str, list[str]] = {
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
# Serving
"venue_pricing_benchmarks": ["fct_daily_availability"],
"city_market_profile": ["dim_cities", "venue_pricing_benchmarks"],
"planner_defaults": ["venue_pricing_benchmarks", "city_market_profile"],
"location_opportunity_profile": ["dim_locations"],
"location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
"planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
"pseo_city_costs_de": [
"city_market_profile", "planner_defaults", "location_opportunity_profile",
"location_profiles", "planner_defaults",
],
"pseo_city_pricing": ["venue_pricing_benchmarks", "city_market_profile"],
"pseo_city_pricing": ["venue_pricing_benchmarks", "location_profiles"],
"pseo_country_overview": ["pseo_city_costs_de"],
}
@@ -298,11 +299,8 @@ async def _inject_sidebar_data():
"""Load unread inbox count for the admin sidebar badge."""
from quart import g
from ..core import fetch_one
try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
except Exception:
g.admin_unread_count = 0
@@ -541,6 +539,7 @@ def _load_workflows() -> list[dict]:
"schedule": schedule,
"schedule_label": schedule_label,
"depends_on": config.get("depends_on", []),
"description": config.get("description", ""),
})
return workflows
@@ -780,7 +779,8 @@ async def pipeline_trigger_extract():
else:
await enqueue("run_extraction")
is_htmx = request.headers.get("HX-Request") == "true"
is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx:
return await _render_overview_partial()
@@ -1005,7 +1005,8 @@ async def pipeline_trigger_transform():
(task_name,),
)
if existing:
is_htmx = request.headers.get("HX-Request") == "true"
is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx:
return await _render_transform_partial()
await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning")
@@ -1013,7 +1014,8 @@ async def pipeline_trigger_transform():
await enqueue(task_name)
is_htmx = request.headers.get("HX-Request") == "true"
is_htmx = (request.headers.get("HX-Request") == "true"
and request.headers.get("HX-Boosted") != "true")
if is_htmx:
return await _render_transform_partial()

View File

@@ -25,7 +25,7 @@ from ..content.health import (
get_template_freshness,
get_template_stats,
)
from ..core import csrf_protect, fetch_all, fetch_one
from ..core import count_where, csrf_protect, fetch_all, fetch_one
bp = Blueprint(
"pseo",
@@ -41,8 +41,7 @@ async def _inject_sidebar_data():
from quart import g
try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
except Exception:
g.admin_unread_count = 0
@@ -80,8 +79,7 @@ async def pseo_dashboard():
total_published = sum(r["stats"]["published"] for r in template_rows)
stale_count = sum(1 for f in freshness if f["status"] == "stale")
noindex_row = await fetch_one("SELECT COUNT(*) as cnt FROM articles WHERE noindex = 1")
noindex_count = noindex_row["cnt"] if noindex_row else 0
noindex_count = await count_where("articles WHERE noindex = 1")
# Recent generation jobs — enough for the dashboard summary.
jobs = await fetch_all(

File diff suppressed because it is too large Load Diff

View File

@@ -226,10 +226,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
</div>
{% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this product? This cannot be undone.', this.closest('form'))">Delete</button>
</form>
{% endif %}
</div>

View File

@@ -120,10 +120,9 @@ document.addEventListener('DOMContentLoaded', function() {
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
</div>
{% if editing %}
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0">
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0" hx-boost="true">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline"
onclick="event.preventDefault(); confirmAction('Delete this program? Blocked if products reference it.', this.closest('form'))">Delete</button>
</form>
{% endif %}
</div>

Some files were not shown because too many files have changed in this diff Show More