Compare commits

..

48 Commits

Author SHA1 Message Date
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
64 changed files with 3292 additions and 951 deletions

View File

@@ -6,6 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **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.
@@ -13,6 +58,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **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.

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

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

@@ -82,6 +82,21 @@ def normalize_eurostat_nuts(evaluator, code_col) -> str:
)
@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.

View File

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

View File

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

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

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

15
uv.lock generated
View File

@@ -1392,6 +1392,7 @@ dependencies = [
{ name = "pyyaml" },
{ name = "quart" },
{ name = "resend" },
{ name = "stripe" },
{ name = "weasyprint" },
]
@@ -1413,6 +1414,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 +2521,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

@@ -532,6 +532,71 @@ async def lead_results():
)
@bp.route("/leads/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def leads_bulk():
"""Bulk actions on leads: set_status, set_heat."""
form = await request.form
ids_raw = form.get("lead_ids", "").strip()
action = form.get("action", "").strip()
if action not in ("set_status", "set_heat") or not ids_raw:
return "", 400
lead_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(lead_ids) <= 500, "too many lead IDs in bulk action"
if not lead_ids:
return "", 400
placeholders = ",".join("?" for _ in lead_ids)
if action == "set_status":
target = form.get("target_status", "").strip()
if target not in LEAD_STATUSES:
return "", 400
await execute(
f"UPDATE lead_requests SET status = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
elif action == "set_heat":
target = form.get("target_heat", "").strip()
if target not in HEAT_OPTIONS:
return "", 400
await execute(
f"UPDATE lead_requests SET heat_score = ? WHERE id IN ({placeholders})",
(target, *lead_ids),
)
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
heat_filter = form.get("heat", "")
country_filter = form.get("country", "")
days_str = form.get("days", "")
days = int(days_str) if days_str.isdigit() else None
per_page = 50
lead_list, total = await get_leads(
status=status_filter or None, heat=heat_filter or None,
country=country_filter or None, search=search or None,
days=days, page=1, per_page=per_page,
)
return await render_template(
"admin/partials/lead_results.html",
leads=lead_list,
page=1,
per_page=per_page,
total=total,
current_status=status_filter,
current_heat=heat_filter,
current_country=country_filter,
current_search=search,
current_days=days_str,
)
@bp.route("/leads/<int:lead_id>")
@role_required("admin")
async def lead_detail(lead_id: int):
@@ -2121,6 +2186,27 @@ _ARTICLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "co
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _find_article_md(slug: str) -> Path | None:
"""Return the Path of the .md file whose frontmatter slug matches, or None.
Tries the exact name first ({slug}.md), then scans _ARTICLES_DIR for any
file whose YAML frontmatter contains 'slug: <slug>'. This handles the
common pattern where files are named {slug}-{lang}.md but the frontmatter
slug omits the language suffix.
"""
if not _ARTICLES_DIR.is_dir():
return None
exact = _ARTICLES_DIR / f"{slug}.md"
if exact.exists():
return exact
for path in _ARTICLES_DIR.glob("*.md"):
raw = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(raw)
if m and f"slug: {slug}" in m.group(1):
return path
return None
async def _sync_static_articles() -> None:
"""Upsert static .md articles from data/content/articles/ into the DB.
@@ -2409,6 +2495,101 @@ async def article_results():
)
@bp.route("/articles/bulk", methods=["POST"])
@role_required("admin")
@csrf_protect
async def articles_bulk():
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip()
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions or not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids:
return "", 400
placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
elif action == "rebuild":
for aid in article_ids:
await _rebuild_article(aid)
elif action == "delete":
from ..content.routes import BUILD_DIR
articles = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
for a in articles:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
# Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
grouped = not language_filter
if grouped:
article_list = await _get_article_list_grouped(
status=status_filter or None, template_slug=template_filter or None,
search=search or None,
)
else:
article_list = await _get_article_list(
status=status_filter or None, template_slug=template_filter or None,
language=language_filter or None, search=search or None,
)
return await render_template(
"admin/partials/article_results.html",
articles=article_list,
grouped=grouped,
page=1,
is_generating=await _is_generating(),
)
@bp.route("/articles/new", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
@@ -2437,11 +2618,11 @@ async def article_new():
if not title or not body:
await flash("Title and body are required.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False)
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False)
return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
# Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body)
@@ -2474,7 +2655,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False)
return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@@ -2510,7 +2691,7 @@ async def article_edit(article_id: int):
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template(
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
)
# Re-render if body provided
@@ -2544,18 +2725,55 @@ async def article_edit(article_id: int):
# Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
md_path = _find_article_md(article["slug"])
if md_path is None:
lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else ""
fallback = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
md_path = fallback if fallback.exists() else None
raw = md_path.read_text() if md_path else ""
# Strip YAML frontmatter so only the prose body appears in the editor
m = _FRONTMATTER_RE.match(raw)
body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
data = {**dict(article), "body": body}
return await render_template(
"admin/article_form.html", data=data, editing=True, article_id=article_id,
"admin/article_form.html",
data=data,
editing=True,
article_id=article_id,
preview_doc=preview_doc,
)
@bp.route("/articles/preview", methods=["POST"])
@role_required("admin")
@csrf_protect
async def article_preview():
"""Render markdown body to HTML for the live editor preview panel."""
form = await request.form
body = form.get("body", "")
m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body
body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect

View File

@@ -1,89 +1,413 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "articles" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Admin {{ config.APP_NAME }}{% endblock %}
{% block head %}{{ super() }}
<style>
/* Override admin-main so the split editor fills the column */
.admin-main {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Editor shell ──────────────────────────────────────────── */
.ae-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Toolbar ────────────────────────────────────────────────── */
.ae-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
background: #fff;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__back {
font-size: 0.8125rem;
color: #64748B;
text-decoration: none;
flex-shrink: 0;
transition: color 0.1s;
}
.ae-toolbar__back:hover { color: #0F172A; }
.ae-toolbar__sep {
width: 1px; height: 1.25rem;
background: #E2E8F0;
flex-shrink: 0;
}
.ae-toolbar__title {
font-size: 0.875rem;
font-weight: 600;
color: #0F172A;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ae-toolbar__status {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
flex-shrink: 0;
}
.ae-toolbar__status--draft {
background: #F1F5F9;
color: #64748B;
}
.ae-toolbar__status--published {
background: #DCFCE7;
color: #16A34A;
}
/* ── Metadata strip ─────────────────────────────────────────── */
#ae-form {
display: contents; /* form participates in flex layout as transparent wrapper */
}
.ae-meta {
padding: 0.75rem 1.25rem;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-meta__row {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
align-items: end;
}
.ae-meta__row + .ae-meta__row { margin-top: 0.5rem; }
.ae-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.ae-field--flex1 { flex: 1; min-width: 120px; }
.ae-field--flex2 { flex: 2; min-width: 180px; }
.ae-field--flex3 { flex: 3; min-width: 220px; }
.ae-field--fixed80 { flex: 0 0 80px; }
.ae-field--fixed120 { flex: 0 0 120px; }
.ae-field--fixed160 { flex: 0 0 160px; }
.ae-field label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94A3B8;
white-space: nowrap;
}
.ae-field input,
.ae-field select {
width: 100%;
padding: 0.3rem 0.5rem;
border: 1px solid #E2E8F0;
border-radius: 4px;
font-size: 0.8125rem;
font-family: var(--font-sans);
color: #0F172A;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
min-width: 0;
}
.ae-field input:focus,
.ae-field select:focus {
border-color: #1D4ED8;
box-shadow: 0 0 0 2px rgba(29,78,216,0.1);
}
.ae-field input[readonly] {
background: #F1F5F9;
color: #94A3B8;
}
/* ── Split pane ─────────────────────────────────────────────── */
.ae-split {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
min-height: 0;
overflow: hidden;
}
.ae-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ae-pane--editor { border-right: 1px solid #E2E8F0; }
.ae-pane__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.875rem;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-pane--preview .ae-pane__header {
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
}
.ae-pane__label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: #94A3B8;
}
.ae-pane__hint {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
}
/* The markdown textarea */
.ae-editor {
flex: 1;
resize: none;
border: none;
outline: none;
padding: 1.5rem 2rem;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.8;
background: #FEFDFB;
color: #1E293B;
caret-color: #1D4ED8;
tab-size: 2;
}
.ae-editor::placeholder { color: #CBD5E1; }
.ae-editor:focus { outline: none; }
/* Preview pane — iframe fills the content area */
#ae-preview-content {
flex: 1;
display: flex;
min-height: 0;
}
.preview-placeholder {
font-size: 0.875rem;
color: #94A3B8;
font-style: italic;
margin: 1.5rem 2rem;
}
/* Collapsible metadata */
.ae-meta--collapsed { display: none; }
.ae-toolbar__toggle {
font-size: 0.75rem;
font-weight: 600;
color: #64748B;
background: none;
border: 1px solid #E2E8F0;
border-radius: 4px;
padding: 0.25rem 0.6rem;
cursor: pointer;
flex-shrink: 0;
}
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
/* Word count footer */
.ae-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.25rem 0.875rem;
background: #F8FAFC;
border-top: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-wordcount {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
}
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
.ae-loading {
font-size: 0.625rem;
color: #94A3B8;
font-family: var(--font-mono);
opacity: 0;
transition: opacity 0.2s;
}
.ae-loading.htmx-request { opacity: 1; }
/* Responsive: stack on narrow screens */
@media (max-width: 900px) {
.ae-split { grid-template-columns: 1fr; }
.ae-pane--preview { display: none; }
}
</style>
{% endblock %}
{% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
<div class="ae-shell">
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Toolbar -->
<div class="ae-toolbar">
<a href="{{ url_for('admin.articles') }}" class="ae-toolbar__back">← Articles</a>
<div class="ae-toolbar__sep"></div>
<span class="ae-toolbar__title">
{% if editing %}{{ data.get('title', 'Edit Article') }}{% else %}New Article{% endif %}
</span>
{% if editing %}
<span class="ae-toolbar__status ae-toolbar__status--{{ data.get('status', 'draft') }}">
{{ data.get('status', 'draft') }}
</span>
{% endif %}
<button type="button" class="ae-toolbar__toggle"
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
<button form="ae-form" type="submit" class="btn btn-sm">
{% if editing %}Save Changes{% else %}Create Article{% endif %}
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
<!-- Form wraps everything below the toolbar -->
<form id="ae-form" method="post" style="display:contents;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Metadata strip -->
<div class="ae-meta">
<div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}"
required placeholder="Article title…">
</div>
<div>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}>
<div class="ae-field ae-field--flex2">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}"
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="url_path">URL Path</label>
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
placeholder="e.g. /padel-court-cost-miami">
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
class="form-input" maxlength="160">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
placeholder="e.g. US">
<div class="ae-field ae-field--flex2">
<label for="url_path">URL Path</label>
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}"
placeholder="/slug">
</div>
<div>
<label class="form-label" for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
placeholder="e.g. North America">
</div>
<div>
<label class="form-label" for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
</div>
</div>
<div class="mb-4">
<label class="form-label" for="body">Body (Markdown)</label>
<textarea id="body" name="body" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="language">Language</label>
<select id="language" name="language" class="form-input">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>English (en)</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>German (de)</option>
<div class="ae-field ae-field--fixed80">
<label for="language">Language</label>
<select id="language" name="language">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>EN</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
</select>
</div>
<div>
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
<div class="ae-field ae-field--fixed120">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft" {% if data.get('status', 'draft') == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
</select>
</div>
<div>
<label class="form-label" for="published_at">Publish Date</label>
</div>
<div class="ae-meta__row">
<div class="ae-field ae-field--flex3">
<label for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description"
value="{{ data.get('meta_description', '') }}" maxlength="160"
placeholder="160 chars max…">
</div>
<div class="ae-field ae-field--flex1">
<label for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}"
placeholder="e.g. US">
</div>
<div class="ae-field ae-field--flex1">
<label for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}"
placeholder="e.g. North America">
</div>
<div class="ae-field ae-field--flex2">
<label for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url"
value="{{ data.get('og_image_url', '') }}">
</div>
<div class="ae-field ae-field--fixed160">
<label for="published_at">Publish Date</label>
<input type="datetime-local" id="published_at" name="published_at"
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}">
</div>
</div>
</div>
<!-- Split: editor | preview -->
<div class="ae-split">
<!-- Left — Markdown editor -->
<div class="ae-pane ae-pane--editor">
<div class="ae-pane__header">
<span class="ae-pane__label">Markdown</span>
<span class="ae-pane__hint">[scenario:slug] · [product:slug]</span>
</div>
<textarea
id="body" name="body"
class="ae-editor"
{% if not editing %}required{% endif %}
placeholder="Start writing in Markdown…"
hx-post="{{ url_for('admin.article_preview') }}"
hx-trigger="input delay:500ms"
hx-target="#ae-preview-content"
hx-include="[name=csrf_token]"
hx-indicator="#ae-loading"
>{{ data.get('body', '') }}</textarea>
<div class="ae-footer">
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
</div>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
</form>
</div>
<!-- Right — Rendered preview -->
<div class="ae-pane ae-pane--preview">
<div class="ae-pane__header">
<span class="ae-pane__label">Preview</span>
<span id="ae-loading" class="ae-loading">Rendering…</span>
</div>
<div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin"
title="Article preview"
></iframe>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}
</div>
</div>
</div>
</form>
</div>
<script>
(function () {
var textarea = document.getElementById('body');
var counter = document.getElementById('ae-wordcount');
function updateCount() {
var text = textarea.value.trim();
var count = text ? text.split(/\s+/).length : 0;
counter.textContent = count + (count === 1 ? ' word' : ' words');
}
textarea.addEventListener('input', updateCount);
updateCount();
}());
</script>
{% endblock %}

View File

@@ -70,8 +70,91 @@
</form>
</div>
{# Bulk action bar #}
<form id="article-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="template" value="{{ current_template }}">
<input type="hidden" name="language" value="{{ current_language }}">
</form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option>
<option value="publish">Publish</option>
<option value="unpublish">Unpublish</option>
<option value="toggle_noindex">Toggle noindex</option>
<option value="rebuild">Rebuild</option>
<option value="delete">Delete</option>
</select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
</div>
{# Results #}
<div id="article-results">
{% include "admin/partials/article_results.html" %}
</div>
<script>
const articleSelectedIds = new Set();
function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id);
updateArticleBulkBar();
}
function clearArticleSelection() {
articleSelectedIds.clear();
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false;
updateArticleBulkBar();
}
function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(',');
}
function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value;
if (!action) return;
if (articleSelectedIds.size === 0) return;
function doSubmit() {
document.getElementById('article-bulk-action').value = action;
htmx.ajax('POST', '{{ url_for("admin.articles_bulk") }}', {
source: document.getElementById('article-bulk-form'),
target: '#article-results',
swap: 'innerHTML'
});
clearArticleSelection();
}
if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
if (ok) doSubmit();
});
} else {
doSubmit();
}
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
if (articleSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
});
}
});
</script>
{% endblock %}

View File

@@ -126,8 +126,103 @@
</form>
</div>
<!-- Bulk action bar -->
<form id="lead-bulk-form" style="display:none">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="lead_ids" id="lead-bulk-ids" value="">
<input type="hidden" name="action" id="lead-bulk-action" value="">
<input type="hidden" name="target_status" id="lead-bulk-target-status" value="">
<input type="hidden" name="target_heat" id="lead-bulk-target-heat" value="">
<input type="hidden" name="search" value="{{ current_search }}">
<input type="hidden" name="status" value="{{ current_status }}">
<input type="hidden" name="heat" value="{{ current_heat }}">
<input type="hidden" name="country" value="{{ current_country }}">
<input type="hidden" name="days" value="{{ current_days }}">
</form>
<div id="lead-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="lead-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="lead-bulk-action-select" class="form-input" style="min-width:120px;padding:0.25rem 0.5rem;font-size:0.8125rem" onchange="onLeadActionChange()">
<option value="">Action…</option>
<option value="set_status">Set Status</option>
<option value="set_heat">Set Heat</option>
</select>
<select id="lead-status-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for s in statuses %}
<option value="{{ s }}">{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<select id="lead-heat-select" class="form-input" style="min-width:100px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
{% for h in heat_options %}
<option value="{{ h }}">{{ h | upper }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm" onclick="submitLeadBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearLeadSelection()">Clear</button>
</div>
<!-- Results -->
<div id="lead-results">
{% include "admin/partials/lead_results.html" %}
</div>
<script>
const leadSelectedIds = new Set();
function toggleLeadSelect(id, checked) {
if (checked) leadSelectedIds.add(id);
else leadSelectedIds.delete(id);
updateLeadBulkBar();
}
function clearLeadSelection() {
leadSelectedIds.clear();
document.querySelectorAll('.lead-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('lead-select-all');
if (selectAll) selectAll.checked = false;
updateLeadBulkBar();
}
function updateLeadBulkBar() {
var bar = document.getElementById('lead-bulk-bar');
var count = document.getElementById('lead-bulk-count');
var ids = document.getElementById('lead-bulk-ids');
bar.style.display = leadSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = leadSelectedIds.size + ' selected';
ids.value = Array.from(leadSelectedIds).join(',');
}
function onLeadActionChange() {
var action = document.getElementById('lead-bulk-action-select').value;
document.getElementById('lead-status-select').style.display = action === 'set_status' ? '' : 'none';
document.getElementById('lead-heat-select').style.display = action === 'set_heat' ? '' : 'none';
}
function submitLeadBulk() {
var action = document.getElementById('lead-bulk-action-select').value;
if (!action) return;
if (leadSelectedIds.size === 0) return;
document.getElementById('lead-bulk-action').value = action;
if (action === 'set_status') {
document.getElementById('lead-bulk-target-status').value = document.getElementById('lead-status-select').value;
} else if (action === 'set_heat') {
document.getElementById('lead-bulk-target-heat').value = document.getElementById('lead-heat-select').value;
}
htmx.ajax('POST', '{{ url_for("admin.leads_bulk") }}', {
source: document.getElementById('lead-bulk-form'),
target: '#lead-results',
swap: 'innerHTML'
});
clearLeadSelection();
}
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'lead-results') {
document.querySelectorAll('.lead-checkbox').forEach(function(cb) {
if (leadSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
});
}
});
</script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
<td></td>
<td style="max-width:260px">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
<div class="article-subtitle">{{ g.url_path }}</div>

View File

@@ -0,0 +1,12 @@
{# HTMX partial: sandboxed iframe showing a rendered article preview.
Rendered by POST /admin/articles/preview. #}
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin"
title="Article preview"
></iframe>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}

View File

@@ -54,6 +54,11 @@
<table class="table text-sm">
<thead>
<tr>
{% if not grouped %}
<th style="width:32px"><input type="checkbox" id="article-select-all" onchange="document.querySelectorAll('.article-checkbox').forEach(cb => { cb.checked = this.checked; toggleArticleSelect(Number(cb.dataset.id), this.checked); })"></th>
{% else %}
<th style="width:32px"></th>
{% endif %}
<th>Title</th>
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
<th>Published</th>

View File

@@ -1,4 +1,8 @@
<tr id="article-{{ a.id }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="article-checkbox" data-id="{{ a.id }}"
onchange="toggleArticleSelect({{ a.id }}, this.checked)">
</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
title="{{ a.url_path }}">{{ a.title }}</td>
<td>

View File

@@ -29,6 +29,7 @@
<table class="table">
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="lead-select-all" onchange="document.querySelectorAll('.lead-checkbox').forEach(cb => { cb.checked = this.checked; toggleLeadSelect(Number(cb.dataset.id), this.checked); })"></th>
<th>ID</th>
<th>Heat</th>
<th>Contact</th>
@@ -43,6 +44,10 @@
<tbody>
{% for lead in leads %}
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
<td onclick="event.stopPropagation()">
<input type="checkbox" class="lead-checkbox" data-id="{{ lead.id }}"
onchange="toggleLeadSelect({{ lead.id }}, this.checked)">
</td>
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
<td>{{ heat_badge(lead.heat_score) }}</td>
<td>

View File

View File

@@ -0,0 +1,116 @@
"""
Paddle payment provider — checkout, webhook verification, subscription management.
Exports the 5 functions that billing/routes.py dispatches to:
- build_checkout_payload()
- build_multi_item_checkout_payload()
- cancel_subscription()
- get_management_url()
- handle_webhook()
"""
import json
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from ..core import config
def _paddle_client() -> PaddleClient:
"""Create a Paddle SDK client."""
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
class _WebhookRequest:
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
def __init__(self, body: bytes, headers):
self.body = body
self.headers = headers
_verifier = Verifier(maximum_variance=300)
def build_checkout_payload(
price_id: str, custom_data: dict, success_url: str,
) -> dict:
"""Build JSON payload for a single-item Paddle.js overlay checkout."""
return {
"items": [{"priceId": price_id, "quantity": 1}],
"customData": custom_data,
"settings": {"successUrl": success_url},
}
def build_multi_item_checkout_payload(
items: list[dict], custom_data: dict, success_url: str,
) -> dict:
"""Build JSON payload for a multi-item Paddle.js overlay checkout."""
return {
"items": items,
"customData": custom_data,
"settings": {"successUrl": success_url},
}
def cancel_subscription(provider_subscription_id: str) -> None:
"""Cancel a Paddle subscription at end of current billing period."""
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
paddle = _paddle_client()
paddle.subscriptions.cancel(
provider_subscription_id,
CancelSubscription(effective_from="next_billing_period"),
)
def get_management_url(provider_subscription_id: str) -> str:
"""Get the Paddle customer portal URL for updating payment method."""
paddle = _paddle_client()
paddle_sub = paddle.subscriptions.get(provider_subscription_id)
return paddle_sub.management_urls.update_payment_method
def verify_webhook(payload: bytes, headers) -> bool:
"""Verify Paddle webhook signature. Returns True if valid or no secret configured."""
if not config.PADDLE_WEBHOOK_SECRET:
return True
try:
return _verifier.verify(
_WebhookRequest(payload, headers),
Secret(config.PADDLE_WEBHOOK_SECRET),
)
except (ConnectionRefusedError, ValueError):
return False
def parse_webhook(payload: bytes) -> dict:
"""Parse a Paddle webhook payload into a normalized event dict.
Returns dict with keys: event_type, subscription_id, customer_id,
user_id, supplier_id, plan, status, current_period_end, data, items.
"""
event = json.loads(payload)
event_type = event.get("event_type", "")
data = event.get("data") or {}
custom_data = data.get("custom_data") or {}
billing_period = data.get("current_billing_period") or {}
return {
"event_type": event_type,
"subscription_id": data.get("id", ""),
"customer_id": str(data.get("customer_id", "")),
"user_id": custom_data.get("user_id"),
"supplier_id": custom_data.get("supplier_id"),
"plan": custom_data.get("plan", ""),
"status": data.get("status", ""),
"current_period_end": billing_period.get("ends_at"),
"data": data,
"items": data.get("items", []),
"custom_data": custom_data,
}

View File

@@ -1,6 +1,9 @@
"""
Billing domain: checkout, webhooks, subscription management.
Payment provider: paddle
Provider dispatch: PAYMENT_PROVIDER env var selects 'paddle' or 'stripe'.
Both webhook endpoints (/webhook/paddle and /webhook/stripe) stay active
regardless of the toggle — existing subscribers keep sending webhooks.
"""
import json
@@ -8,20 +11,21 @@ import secrets
from datetime import timedelta
from pathlib import Path
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required
from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso
from ..core import config, execute, fetch_one, get_price_id, utcnow, utcnow_iso
from ..i18n import get_translations
def _paddle_client() -> PaddleClient:
"""Create a Paddle SDK client. Used only for subscription management + webhook verification."""
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
def _provider():
"""Return the active payment provider module."""
if config.PAYMENT_PROVIDER == "stripe":
from . import stripe as mod
else:
from . import paddle as mod
return mod
# Blueprint with its own template folder
bp = Blueprint(
@@ -33,7 +37,7 @@ bp = Blueprint(
# =============================================================================
# SQL Queries
# SQL Queries (provider-agnostic)
# =============================================================================
async def get_subscription(user_id: int) -> dict | None:
@@ -132,7 +136,7 @@ async def is_within_limits(user_id: int, resource: str, current_count: int) -> b
# =============================================================================
# Routes
# Routes (provider-agnostic)
# =============================================================================
@bp.route("/pricing")
@@ -151,129 +155,171 @@ async def success():
return await render_template("success.html")
# =============================================================================
# Paddle Implementation — Paddle.js Overlay Checkout
# Checkout / Manage / Cancel — dispatched to active provider
# =============================================================================
@bp.route("/checkout/<plan>", methods=["POST"])
@login_required
async def checkout(plan: str):
"""Return JSON for Paddle.js overlay checkout."""
price_id = await get_paddle_price(plan)
"""Return JSON for checkout (overlay for Paddle, redirect URL for Stripe)."""
price_id = await get_price_id(plan)
if not price_id:
return jsonify({"error": "Invalid plan selected."}), 400
return jsonify({
"items": [{"priceId": price_id, "quantity": 1}],
"customData": {"user_id": str(g.user["id"]), "plan": plan},
"settings": {
"successUrl": f"{config.BASE_URL}/billing/success",
},
})
payload = _provider().build_checkout_payload(
price_id=price_id,
custom_data={"user_id": str(g.user["id"]), "plan": plan},
success_url=f"{config.BASE_URL}/billing/success",
)
return jsonify(payload)
@bp.route("/checkout/item", methods=["POST"])
@login_required
async def checkout_item():
"""Return checkout JSON for a single item (boost, credit pack, etc.).
Used by dashboard boost/credit buttons that need a server round-trip
for Stripe (Checkout Session creation) and work with Paddle overlay too.
Expects JSON body: {price_key, custom_data, success_url?}
"""
body = await request.get_json(silent=True) or {}
price_key = body.get("price_key", "")
custom_data = body.get("custom_data", {})
success_url = body.get("success_url", f"{config.BASE_URL}/suppliers/dashboard?tab=boosts")
price_id = await get_price_id(price_key)
if not price_id:
return jsonify({"error": "Product not configured."}), 400
payload = _provider().build_checkout_payload(
price_id=price_id,
custom_data=custom_data,
success_url=success_url,
)
return jsonify(payload)
@bp.route("/manage", methods=["POST"])
@login_required
async def manage():
"""Redirect to Paddle customer portal."""
"""Redirect to payment provider's customer portal."""
sub = await get_subscription(g.user["id"])
if not sub or not sub.get("provider_subscription_id"):
t = get_translations(g.get("lang") or "en")
await flash(t["billing_no_subscription"], "error")
return redirect(url_for("dashboard.settings"))
paddle = _paddle_client()
paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"])
portal_url = paddle_sub.management_urls.update_payment_method
portal_url = _provider().get_management_url(sub["provider_subscription_id"])
return redirect(portal_url)
@bp.route("/cancel", methods=["POST"])
@login_required
async def cancel():
"""Cancel subscription via Paddle API."""
"""Cancel subscription via active payment provider."""
sub = await get_subscription(g.user["id"])
if sub and sub.get("provider_subscription_id"):
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
paddle = _paddle_client()
paddle.subscriptions.cancel(
sub["provider_subscription_id"],
CancelSubscription(effective_from="next_billing_period"),
)
_provider().cancel_subscription(sub["provider_subscription_id"])
return redirect(url_for("dashboard.settings"))
class _WebhookRequest:
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
def __init__(self, body: bytes, headers):
self.body = body
self.headers = headers
_verifier = Verifier(maximum_variance=300)
# =============================================================================
# Paddle Webhook — always active (existing subscribers keep sending)
# =============================================================================
@bp.route("/webhook/paddle", methods=["POST"])
async def webhook():
"""Handle Paddle webhooks."""
async def webhook_paddle():
"""Handle Paddle webhooks — always active regardless of PAYMENT_PROVIDER toggle."""
from . import paddle as paddle_mod
payload = await request.get_data()
if config.PADDLE_WEBHOOK_SECRET:
try:
ok = _verifier.verify(
_WebhookRequest(payload, request.headers),
Secret(config.PADDLE_WEBHOOK_SECRET),
)
except (ConnectionRefusedError, ValueError):
ok = False
if not ok:
return jsonify({"error": "Invalid signature"}), 400
if not paddle_mod.verify_webhook(payload, request.headers):
return jsonify({"error": "Invalid signature"}), 400
try:
event = json.loads(payload)
ev = paddle_mod.parse_webhook(payload)
except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid JSON payload"}), 400
event_type = event.get("event_type")
data = event.get("data") or {}
custom_data = data.get("custom_data") or {}
user_id = custom_data.get("user_id")
plan = custom_data.get("plan", "")
# Store billing customer for any subscription event with a customer_id
customer_id = str(data.get("customer_id", ""))
await _handle_webhook_event(ev)
return jsonify({"received": True})
# =============================================================================
# Stripe Webhook — always active (once Stripe is configured)
# =============================================================================
@bp.route("/webhook/stripe", methods=["POST"])
async def webhook_stripe():
"""Handle Stripe webhooks — always active regardless of PAYMENT_PROVIDER toggle."""
if not config.STRIPE_WEBHOOK_SECRET:
return jsonify({"error": "Stripe not configured"}), 404
from . import stripe as stripe_mod
payload = await request.get_data()
if not stripe_mod.verify_webhook(payload, request.headers):
return jsonify({"error": "Invalid signature"}), 400
try:
ev = stripe_mod.parse_webhook(payload)
except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid payload"}), 400
await _handle_webhook_event(ev)
return jsonify({"received": True})
# =============================================================================
# Shared Webhook Event Handler (provider-agnostic)
# =============================================================================
async def _handle_webhook_event(ev: dict) -> None:
"""Process a normalized webhook event from any provider.
ev keys: event_type, subscription_id, customer_id, user_id, supplier_id,
plan, status, current_period_end, data, items, custom_data
"""
event_type = ev.get("event_type", "")
user_id = ev.get("user_id")
plan = ev.get("plan", "")
# Store billing customer
customer_id = ev.get("customer_id", "")
if customer_id and user_id:
await upsert_billing_customer(int(user_id), customer_id)
if event_type == "subscription.activated":
if plan.startswith("supplier_"):
await _handle_supplier_subscription_activated(data, custom_data)
await _handle_supplier_subscription_activated(ev)
elif user_id:
await upsert_subscription(
user_id=int(user_id),
plan=plan or "starter",
status="active",
provider_subscription_id=data.get("id", ""),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
provider_subscription_id=ev.get("subscription_id", ""),
current_period_end=ev.get("current_period_end"),
)
elif event_type == "subscription.updated":
await update_subscription_status(
data.get("id", ""),
status=data.get("status", "active"),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
ev.get("subscription_id", ""),
status=ev.get("status", "active"),
current_period_end=ev.get("current_period_end"),
)
elif event_type == "subscription.canceled":
await update_subscription_status(data.get("id", ""), status="cancelled")
await update_subscription_status(ev.get("subscription_id", ""), status="cancelled")
elif event_type == "subscription.past_due":
await update_subscription_status(data.get("id", ""), status="past_due")
await update_subscription_status(ev.get("subscription_id", ""), status="past_due")
elif event_type == "transaction.completed":
await _handle_transaction_completed(data, custom_data)
return jsonify({"received": True})
await _handle_transaction_completed(ev)
# =============================================================================
@@ -301,7 +347,13 @@ BOOST_PRICE_KEYS = {
async def _price_id_to_key(price_id: str) -> str | None:
"""Reverse-lookup a paddle_products key from a Paddle price ID."""
"""Reverse-lookup a product key from a provider price ID."""
row = await fetch_one(
"SELECT key FROM payment_products WHERE provider_price_id = ?", (price_id,)
)
if row:
return row["key"]
# Fallback to old table for pre-migration DBs
row = await fetch_one(
"SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,)
)
@@ -330,13 +382,13 @@ def _derive_tier_from_plan(plan: str) -> tuple[str, str]:
return base, tier
async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None:
async def _handle_supplier_subscription_activated(ev: dict) -> None:
"""Handle supplier plan subscription activation."""
from ..core import transaction as db_transaction
supplier_id = custom_data.get("supplier_id")
plan = custom_data.get("plan", "supplier_growth")
user_id = custom_data.get("user_id")
supplier_id = ev.get("supplier_id")
plan = ev.get("plan", "supplier_growth")
user_id = ev.get("user_id")
if not supplier_id:
return
@@ -365,7 +417,8 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
)
# Create boost records for items included in the subscription
items = data.get("items", [])
items = ev.get("items", [])
data = ev.get("data", {})
for item in items:
price_id = item.get("price", {}).get("id", "")
key = await _price_id_to_key(price_id)
@@ -388,13 +441,15 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
)
async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
async def _handle_transaction_completed(ev: dict) -> None:
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
supplier_id = custom_data.get("supplier_id")
user_id = custom_data.get("user_id")
supplier_id = ev.get("supplier_id")
user_id = ev.get("user_id")
custom_data = ev.get("custom_data", {})
data = ev.get("data", {})
now = utcnow_iso()
items = data.get("items", [])
items = ev.get("items", [])
for item in items:
price_id = item.get("price", {}).get("id", "")
key = await _price_id_to_key(price_id)

View File

@@ -0,0 +1,364 @@
"""
Stripe payment provider — checkout sessions, webhook handling, subscription management.
Exports the same interface as paddle.py so billing/routes.py can dispatch:
- build_checkout_payload()
- build_multi_item_checkout_payload()
- cancel_subscription()
- get_management_url()
- verify_webhook()
- parse_webhook()
Stripe Tax add-on handles EU VAT collection (must be enabled in Stripe Dashboard).
"""
import json
import logging
import stripe as stripe_sdk
from ..core import config
logger = logging.getLogger(__name__)
def _stripe_client():
"""Configure and return the stripe module with our API key."""
stripe_sdk.api_key = config.STRIPE_SECRET_KEY
stripe_sdk.max_network_retries = 2
return stripe_sdk
def build_checkout_payload(
price_id: str, custom_data: dict, success_url: str,
) -> dict:
"""Create a Stripe Checkout Session for a single item.
Returns {checkout_url: "https://checkout.stripe.com/..."} — the client
JS redirects the browser there (no overlay SDK needed).
"""
s = _stripe_client()
session = s.checkout.Session.create(
mode=_mode_for_price(s, price_id),
line_items=[{"price": price_id, "quantity": 1}],
metadata=custom_data,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=success_url.rsplit("/success", 1)[0] + "/pricing",
automatic_tax={"enabled": True},
tax_id_collection={"enabled": True},
)
return {"checkout_url": session.url}
def build_multi_item_checkout_payload(
items: list[dict], custom_data: dict, success_url: str,
) -> dict:
"""Create a Stripe Checkout Session for multiple line items.
items: list of {"priceId": "price_xxx", "quantity": 1}
"""
s = _stripe_client()
line_items = [{"price": i["priceId"], "quantity": i.get("quantity", 1)} for i in items]
# Determine mode: if any item is recurring, use "subscription".
# Otherwise use "payment" for one-time purchases.
has_recurring = any(_is_recurring_price(s, i["priceId"]) for i in items)
mode = "subscription" if has_recurring else "payment"
session = s.checkout.Session.create(
mode=mode,
line_items=line_items,
metadata=custom_data,
success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=success_url.rsplit("/success", 1)[0],
automatic_tax={"enabled": True},
tax_id_collection={"enabled": True},
)
return {"checkout_url": session.url}
def _mode_for_price(s, price_id: str) -> str:
"""Determine Checkout Session mode from price type."""
try:
price = s.Price.retrieve(price_id)
return "subscription" if price.type == "recurring" else "payment"
except Exception:
# Default to payment if we can't determine
return "payment"
def _is_recurring_price(s, price_id: str) -> bool:
"""Check if a Stripe price is recurring (subscription)."""
try:
price = s.Price.retrieve(price_id)
return price.type == "recurring"
except Exception:
return False
def cancel_subscription(provider_subscription_id: str) -> None:
"""Cancel a Stripe subscription at end of current billing period."""
s = _stripe_client()
s.Subscription.modify(
provider_subscription_id,
cancel_at_period_end=True,
)
def get_management_url(provider_subscription_id: str) -> str:
"""Create a Stripe Billing Portal session and return its URL."""
s = _stripe_client()
# Get customer_id from the subscription
sub = s.Subscription.retrieve(
provider_subscription_id,
)
portal = s.billing_portal.Session.create(
customer=sub.customer,
return_url=f"{config.BASE_URL}/billing/success",
)
return portal.url
def verify_webhook(payload: bytes, headers) -> bool:
"""Verify Stripe webhook signature using the Stripe-Signature header."""
if not config.STRIPE_WEBHOOK_SECRET:
return True
sig_header = headers.get("Stripe-Signature", "")
if not sig_header:
return False
try:
stripe_sdk.Webhook.construct_event(
payload, sig_header, config.STRIPE_WEBHOOK_SECRET,
)
return True
except (stripe_sdk.SignatureVerificationError, ValueError):
return False
def parse_webhook(payload: bytes) -> dict:
"""Parse a Stripe webhook payload into a normalized event dict.
Maps Stripe event types to the shared format used by _handle_webhook_event():
- checkout.session.completed (mode=subscription) → subscription.activated
- customer.subscription.created → subscription.activated
- customer.subscription.updated → subscription.updated
- customer.subscription.deleted → subscription.canceled
- invoice.payment_failed → subscription.past_due
- checkout.session.completed (mode=payment) → transaction.completed
"""
raw = json.loads(payload)
stripe_type = raw.get("type", "")
obj = raw.get("data", {}).get("object", {})
# Extract metadata — Stripe stores custom data in session/subscription metadata
metadata = obj.get("metadata") or {}
# Common fields
customer_id = obj.get("customer", "")
user_id = metadata.get("user_id")
supplier_id = metadata.get("supplier_id")
plan = metadata.get("plan", "")
# Map Stripe events to our shared event types
if stripe_type == "checkout.session.completed":
mode = obj.get("mode", "")
if mode == "subscription":
subscription_id = obj.get("subscription", "")
# Fetch subscription details for period end
period_end = None
if subscription_id:
try:
s = _stripe_client()
sub = s.Subscription.retrieve(
subscription_id,
)
# Stripe API 2026-02+ moved period_end to items
ts = sub.current_period_end
if not ts and sub.get("items", {}).get("data"):
ts = sub["items"]["data"][0].get("current_period_end")
period_end = _unix_to_iso(ts)
except Exception:
logger.warning("Failed to fetch subscription %s for period_end", subscription_id)
return {
"event_type": "subscription.activated",
"subscription_id": subscription_id,
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "active",
"current_period_end": period_end,
"data": obj,
"items": _extract_line_items(obj),
"custom_data": metadata,
}
else:
# One-time payment
return {
"event_type": "transaction.completed",
"subscription_id": "",
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "completed",
"current_period_end": None,
"data": obj,
"items": _extract_line_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.created":
# New subscription — map to subscription.activated so the handler creates the DB row
status = _map_stripe_status(obj.get("status", ""))
return {
"event_type": "subscription.activated",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": status,
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.updated":
status = _map_stripe_status(obj.get("status", ""))
return {
"event_type": "subscription.updated",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": status,
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "customer.subscription.deleted":
return {
"event_type": "subscription.canceled",
"subscription_id": obj.get("id", ""),
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "cancelled",
"current_period_end": _get_period_end(obj),
"data": obj,
"items": _extract_sub_items(obj),
"custom_data": metadata,
}
elif stripe_type == "invoice.payment_failed":
sub_id = obj.get("subscription", "")
return {
"event_type": "subscription.past_due",
"subscription_id": sub_id,
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "past_due",
"current_period_end": None,
"data": obj,
"items": [],
"custom_data": metadata,
}
# Unknown event — return a no-op
return {
"event_type": "",
"subscription_id": "",
"customer_id": str(customer_id),
"user_id": user_id,
"supplier_id": supplier_id,
"plan": plan,
"status": "",
"current_period_end": None,
"data": obj,
"items": [],
"custom_data": metadata,
}
# =============================================================================
# Helpers
# =============================================================================
def _map_stripe_status(stripe_status: str) -> str:
"""Map Stripe subscription status to our internal status."""
mapping = {
"active": "active",
"trialing": "on_trial",
"past_due": "past_due",
"canceled": "cancelled",
"unpaid": "past_due",
"incomplete": "past_due",
"incomplete_expired": "expired",
"paused": "paused",
}
return mapping.get(stripe_status, stripe_status)
def _unix_to_iso(ts) -> str | None:
"""Convert Unix timestamp to ISO string, or None."""
if not ts:
return None
from datetime import UTC, datetime
return datetime.fromtimestamp(int(ts), tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000000Z")
def _get_period_end(obj: dict) -> str | None:
"""Extract current_period_end from subscription or its first item.
Stripe API 2026-02+ moved period fields from subscription to subscription items.
"""
ts = obj.get("current_period_end")
if not ts:
items = obj.get("items", {}).get("data", [])
if items:
ts = items[0].get("current_period_end")
return _unix_to_iso(ts)
def _extract_line_items(session_obj: dict) -> list[dict]:
"""Extract line items from a Checkout Session in Paddle-compatible format.
Stripe sessions don't embed line items directly — we'd need an extra API call.
For webhook handling, the key info (price_id) comes from subscription items.
Returns items in the format: [{"price": {"id": "price_xxx"}}]
"""
# For checkout.session.completed, line_items aren't in the webhook payload.
# The webhook handler for subscription.activated fetches them separately.
# For one-time payments, we can reconstruct from the session's line_items
# via the Stripe API, but to keep webhook handling fast we skip this and
# handle it via the subscription events instead.
return []
def _extract_sub_items(sub_obj: dict) -> list[dict]:
"""Extract items from a Stripe Subscription object in Paddle-compatible format."""
items = sub_obj.get("items", {}).get("data", [])
return [{"price": {"id": item.get("price", {}).get("id", "")}} for item in items]

View File

@@ -49,13 +49,17 @@ class Config:
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
PAYMENT_PROVIDER: str = "paddle"
PAYMENT_PROVIDER: str = _env("PAYMENT_PROVIDER", "paddle").lower()
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")
STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "") or os.getenv("STRIPE_API_PUBLIC_KEY", "")
STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")
UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
@@ -722,16 +726,39 @@ async def purge_deleted(table: str, days: int = 30) -> int:
# =============================================================================
async def get_price_id(key: str, provider: str = None) -> str | None:
"""Look up a provider price ID by product key from the payment_products table."""
provider = provider or config.PAYMENT_PROVIDER
row = await fetch_one(
"SELECT provider_price_id FROM payment_products WHERE provider = ? AND key = ?",
(provider, key),
)
return row["provider_price_id"] if row else None
async def get_all_price_ids(provider: str = None) -> dict[str, str]:
"""Load all price IDs for a provider as a {key: price_id} dict."""
provider = provider or config.PAYMENT_PROVIDER
rows = await fetch_all(
"SELECT key, provider_price_id FROM payment_products WHERE provider = ?",
(provider,),
)
return {r["key"]: r["provider_price_id"] for r in rows}
async def get_paddle_price(key: str) -> str | None:
"""Look up a Paddle price ID by product key from the paddle_products table."""
"""Deprecated: use get_price_id(). Falls back to paddle_products for pre-migration DBs."""
result = await get_price_id(key, provider="paddle")
if result:
return result
# Fallback to old table if payment_products not yet populated
row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
return row["paddle_price_id"] if row else None
async def get_all_paddle_prices() -> dict[str, str]:
"""Load all Paddle price IDs as a {key: price_id} dict."""
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
return {r["key"]: r["paddle_price_id"] for r in rows}
"""Deprecated: use get_all_price_ids()."""
return await get_all_price_ids(provider="paddle")
# =============================================================================
@@ -740,9 +767,14 @@ async def get_all_paddle_prices() -> dict[str, str]:
def slugify(text: str, max_length_chars: int = 80) -> str:
"""Convert text to URL-safe slug."""
"""Convert text to URL-safe slug.
Pre-replaces ß→ss before NFKD normalization so output matches the SQL
@slugify macro (which uses DuckDB STRIP_ACCENTS + REPLACE).
"""
text = text.lower().replace("ß", "ss")
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^\w\s-]", "", text.lower())
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[-\s]+", "-", text).strip("-")
return text[:max_length_chars]

View File

@@ -89,17 +89,17 @@
"flash_verify_invalid": "Ungültiger Verifizierungslink.",
"flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.",
"flash_verify_invalid_lead": "Dieses Angebot wurde bereits verifiziert oder existiert nicht.",
"landing_hero_badge": "Padel-Finanzrechner & Businessplan-Tool",
"landing_hero_h1_1": "Plan Dein Padel-",
"landing_hero_h1_2": "Business in Minuten,",
"landing_hero_h1_3": "nicht Monaten",
"landing_hero_btn_primary": "Jetzt Dein Padel-Business planen →",
"landing_hero_btn_secondary": "Anbieter durchsuchen",
"landing_hero_bullet_1": "Keine Registrierung erforderlich",
"landing_hero_bullet_2": "60+ Variablen",
"landing_hero_bullet_3": "Unbegrenzte Szenarien",
"landing_roi_title": "Schnelle Renditeschätzung",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion in Echtzeit sehen",
"landing_hero_badge": "Das Padel-Gründer-Toolkit — kostenlos",
"landing_hero_h1_1": "Investier in Padel",
"landing_hero_h1_2": "mit Sicherheit,",
"landing_hero_h1_3": "nicht Bauchgefühl",
"landing_hero_btn_primary": "Kostenlosen Businessplan starten →",
"landing_hero_btn_secondary": "Anbieter-Angebote einholen",
"landing_hero_bullet_1": "Kostenlos — ohne Registrierung, ohne Kreditkarte",
"landing_hero_bullet_2": "Bankfertige Kennzahlen (IRR, DSCR, MOIC)",
"landing_hero_bullet_3": "Basiert auf echten Marktdaten",
"landing_roi_title": "Ist Deine Padel-Idee rentabel?",
"landing_roi_subtitle": "Finde es in 30 Sekunden heraus",
"landing_roi_courts": "Plätze",
"landing_roi_rate": "Durchschn. Stundensatz",
"landing_roi_util": "Ziel-Auslastung",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Amortisationszeit",
"landing_roi_annual_roi": "Jährlicher ROI",
"landing_roi_note": "Annahmen: Indoorhalle Mietmodell, 8 €/m² Miete, Personalkosten, 5 % Zinsen, 10-jähriges Darlehen. Amortisation und ROI basieren auf der Gesamtinvestition.",
"landing_roi_cta": "Jetzt Dein Padel-Business planen →",
"landing_roi_cta": "Vollständigen Businessplan erstellen — kostenlos →",
"landing_journey_title": "Deine Reise",
"landing_journey_01": "Analysieren",
"landing_journey_01_badge": "Demnächst",
@@ -118,27 +118,27 @@
"landing_journey_04": "Bauen",
"landing_journey_05": "Wachsen",
"landing_journey_05_badge": "Demnächst",
"landing_features_title": "Für ernsthafte Padel-Unternehmer gebaut",
"landing_feature_1_h3": "60+ Variablen",
"landing_feature_2_h3": "6 Analyse-Tabs",
"landing_feature_3_h3": "Indoor & Outdoor",
"landing_feature_4_h3": "Sensitivitätsanalyse",
"landing_feature_5_h3": "Professionelle Kennzahlen",
"landing_feature_6_h3": "Speichern & Vergleichen",
"landing_supplier_title": "Die richtigen Anbieter für Dein Projekt finden",
"landing_supplier_step_1_title": "Padel-Platz planen",
"landing_supplier_step_2_title": "Angebote einholen",
"landing_supplier_step_3_title": "Vergleichen & Bauen",
"landing_supplier_browse_btn": "Anbieterverzeichnis durchsuchen",
"landing_features_title": "Alles, was Du für eine fundierte Entscheidung brauchst",
"landing_feature_1_h3": "Kenne Deine Zahlen in- und auswendig",
"landing_feature_2_h3": "Bankfertig ab Tag eins",
"landing_feature_3_h3": "Jeder Anlagentyp, jeder Markt",
"landing_feature_4_h3": "Stresstest vor dem Commitment",
"landing_feature_5_h3": "Ersetzt den 5.000-€-Berater",
"landing_feature_6_h3": "Szenarien direkt vergleichen",
"landing_supplier_title": "Bereit zum Bauen? Lass Dich mit verifizierten Anbietern verbinden",
"landing_supplier_step_1_title": "Projekt teilen",
"landing_supplier_step_2_title": "Passende Anbieter finden",
"landing_supplier_step_3_title": "Angebote vergleichen",
"landing_supplier_browse_btn": "Angebote einholen — kostenlos & unverbindlich",
"landing_faq_title": "Häufig gestellte Fragen",
"landing_faq_q1": "Was berechnet der Planer?",
"landing_faq_q2": "Muss ich mich registrieren?",
"landing_faq_q3": "Wie funktioniert die Anbieter-Vermittlung?",
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?",
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?",
"landing_faq_q1": "Wie viel kostet es, eine Padel-Anlage zu eröffnen?",
"landing_faq_q2": "Akzeptiert die Bank einen Padelnomics-Businessplan?",
"landing_faq_q3": "Wie genau sind die Finanzprojektionen?",
"landing_faq_q4": "Auf welchen Daten basieren die Markt-Benchmarks?",
"landing_faq_q5": "Muss ich etwas bezahlen?",
"landing_seo_title": "Padel-Platz-Investitionsplanung",
"landing_final_cta_h2": "Jetzt mit der Planung loslegen",
"landing_final_cta_btn": "Jetzt Dein Padel-Business planen →",
"landing_final_cta_h2": "Dein Banktermin kommt. Sei vorbereitet.",
"landing_final_cta_btn": "Kostenlosen Businessplan starten →",
"features_h1": "Alles, was Du für Dein Padel-Business brauchst",
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
"features_card_1_h2": "60+ Variablen",
@@ -891,7 +891,7 @@
"sup_meta_desc": "Kostenloser Verzeichniseintrag auf Padelnomics. Qualifizierte Leads von Interessenten mit fertigem Businessplan. Growth- und Pro-Pläne ab €199/Monat.",
"sup_hero_h1a": "Kein Kaltakquise mehr.",
"sup_hero_h1b": "Triff Käufer, die bereits einen Businessplan haben.",
"sup_hero_sub": "Jeder Lead auf Padelnomics hat CAPEX, Umsatz und ROI bereits modelliert bevor er dich kontaktiert. Keine Zeitverschwender. Kein „ich schau mich nur um.“",
"sup_hero_sub": "Jeder Lead hat bereits ein Finanzmodell für sein Projekt erstellt. Du bekommst Budget, Zeitplan und Spezifikationen — noch vor dem Erstkontakt.",
"sup_hero_cta": "Kostenlos starten",
"sup_hero_trust_pre": "Vertrauen von Anbietern in",
"sup_hero_trust_post": "Ländern",
@@ -955,7 +955,7 @@
"sup_basic_f4": "Website & Kontaktdaten",
"sup_basic_f5": "Checkliste der angebotenen Leistungen",
"sup_basic_f6": "Kontaktformular auf der Listing-Seite",
"sup_basic_cta": "Unternehmen kostenlos eintragen",
"sup_basic_cta": "Kostenlos eintragen",
"sup_growth_name": "Growth",
"sup_growth_popular": "Beliebtester Plan",
"sup_growth_credits": "30 Credits/Monat inklusive",
@@ -965,7 +965,7 @@
"sup_growth_f4": "Priorität gegenüber kostenlosen Einträgen",
"sup_growth_f5": "30 Lead-Credits pro Monat",
"sup_growth_f6": "Zusätzliche Credit-Pakete kaufen",
"sup_growth_cta": "Jetzt starten",
"sup_growth_cta": "Leads erhalten",
"sup_pro_name": "Pro",
"sup_pro_credits": "100 Credits/Monat inklusive",
"sup_pro_f1": "Alles aus Growth",
@@ -974,7 +974,7 @@
"sup_pro_f4": "Featured Card-Rahmen & Glow",
"sup_pro_f5": "Bevorzugte Platzierung im Verzeichnis",
"sup_pro_f6": "100 Lead-Credits pro Monat",
"sup_pro_cta": "Jetzt starten",
"sup_pro_cta": "Pipeline maximieren",
"sup_yearly_note_basic": "Dauerhaft kostenlos",
"sup_yearly_note_growth": "€1.799 jährlich",
"sup_yearly_note_pro": "€4.499 jährlich",
@@ -1012,14 +1012,14 @@
"sup_cmp_t4": "Nie",
"sup_cmp_m1": "Nach Kategorie gefiltert",
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €2080 CPC für Padel-Baukeywords bei 510 Klicks/Tag.",
"sup_proof_h2": "Vertrauen von führenden Unternehmen der Padel-Branche",
"sup_proof_h2": "Das bekommst du mit jedem Lead",
"sup_proof_stat1": "erstellte Businesspläne",
"sup_proof_stat2": "Anbieter",
"sup_proof_stat3": "Länder",
"sup_proof_q1": "Padelnomics schickt uns Leads, die bereits ernsthaft an einem Bau interessiert sind. Die Projektbriefings sind detaillierter als das, was wir von Messen erhalten.",
"sup_proof_cite1": "— Europäischer Padel-Court-Hersteller",
"sup_proof_q2": "Endlich eine Plattform, die den Padel-Baumarkt versteht. Wir kennen das Budget, den Zeitplan und den Standorttyp, bevor wir überhaupt Erstkontakt aufnehmen.",
"sup_proof_cite2": "— Padel-Court-Installationsunternehmen, Skandinavien",
"sup_proof_point1_h3": "Komplettes Projektbriefing",
"sup_proof_point1_p": "Anlagentyp, Court-Anzahl, Glas-/Lichtspezifikationen, Budget, Zeitplan, Finanzierungsstatus und vollständige Kontaktdaten — bevor du überhaupt Erstkontakt aufnimmst.",
"sup_proof_point2_h3": "Finanzmodell inklusive",
"sup_proof_point2_p": "Jeder Lead hat bereits CAPEX, Umsatzprognosen und ROI durchgerechnet. Du sprichst mit jemandem, der seine Zahlen kennt.",
"sup_faq_h2": "Anbieter-FAQ",
"sup_faq_q1": "Wie werde ich gelistet?",
"sup_faq_a1_pre": "Finde dein Unternehmen in unserem",
@@ -1172,34 +1172,67 @@
"features_opex_body": "Peak- und Off-Peak-Preise mit konfigurierbaren Stundenaufteilungen. Monatliche Anlaufkurven für die Auslastung. Personalkosten, Wartung, Versicherung, Marketing und Betriebskosten — alle mit Schiebereglern anpassbar. Einnahmen aus Platzvermietung, Coaching, Ausrüstung und F&B.",
"features_cf_body": "Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin dein Geld fließt.",
"features_returns_body": "Eigenkapital-IRR und MOIC unter verschiedenen Exit-Szenarien berechnen. Cap-Rate-Exits mit konfigurierbaren Haltedauern modellieren. Die Eigenkapitalentwicklung vom Ersteinsatz bis zum Exit-Erlös nachvollziehen.",
"landing_page_title": "Padelnomics - Padel-Kostenrechner & Finanzplaner",
"landing_meta_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.",
"landing_og_desc": "Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.",
"landing_hero_desc": "Modelliere Deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Danach wirst Du mit verifizierten Anbietern zusammengebracht.",
"landing_page_title": "Padelnomics Padel-Businessplan & Renditerechner | Kostenlos",
"landing_meta_desc": "Plane Deine Padel-Investition mit echten Marktdaten. Bankfertiges Finanzmodell mit IRR, DSCR, Sensitivitätsanalyse. Kostenlos — ohne Registrierung.",
"landing_og_desc": "Plane Deine Padel-Investition mit Sicherheit. Bankfertiges Finanzmodell, echte Marktdaten und verifizierte Anbieter-Vermittlung. Kostenlos — ohne Registrierung.",
"landing_hero_desc": "Du stehst vor einer Investition von über 200.000 €. Padelnomics gibt Dir das Finanzmodell, die Marktdaten und die Anbieter-Kontakte, um diese Entscheidung mit offenen Augen zu treffen.",
"landing_journey_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.",
"landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.",
"landing_journey_03_desc": "Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.",
"landing_journey_04_desc": "{total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu Deinen Anforderungen vermittelt.",
"landing_journey_05_desc": "Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.",
"landing_feature_1_body": "Jede Annahme ist anpassbar: Platzbaukosten, Miete, Preisgestaltung, Auslastung, Finanzierungskonditionen, Exit-Szenarien. Nichts ist fest vorgegeben.",
"landing_feature_2_body": "Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen — jeder Tab mit interaktiven Diagrammen.",
"landing_feature_3_body": "Indoorhallenmodelle (Miete oder Neubau) und Außenanlagen mit Saisonalität. Szenarien direkt nebeneinander vergleichen.",
"landing_feature_4_body": "Sieh dir an, wie sich deine Renditen bei unterschiedlichen Auslastungsraten und Preisen verändern. Break-even-Punkt sofort ermitteln.",
"landing_feature_5_body": "IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Auslastung, RevPAH, Schuldenrendite — die Kennzahlen, die Banken und Investoren sehen möchten.",
"landing_feature_6_body": "Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen und Finanzierungsstrukturen testen. Den optimalen Plan finden.",
"landing_supplier_sub": "{total_suppliers}+ verifizierte Anbieter aus {total_countries} Ländern. Hersteller, Baufirmen, Belaghersteller, Beleuchtung und mehr.",
"landing_supplier_step_1_body": "Nutze den Finanzplaner, um deine Platzzahl, dein Budget und deinen Zeitplan zu modellieren.",
"landing_supplier_step_2_body": "Angebote anfordern — wir vermitteln dich anhand deiner Projektspezifikationen an passende Anbieter.",
"landing_supplier_step_3_body": "Angebote von vermittelten Anbietern erhalten. Keine Kaltakquise erforderlich.",
"landing_faq_a1": "Der Planer erstellt ein vollständiges Finanzmodell: CAPEX-Aufschlüsselung, monatliche Betriebskosten, Cashflow-Projektionen, Schuldendienst, IRR, MOIC, DSCR, Amortisationszeit, Break-even-Auslastung und Sensitivitätsanalyse. Es werden Indoor-/Outdoor-Anlagen, Miet- und Eigentumsmodelle sowie alle wesentlichen Kosten- und Erlösvariablen abgedeckt.",
"landing_faq_a2": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstelle ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.",
"landing_faq_a3": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.",
"landing_faq_a4": "Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne (Basic ab 39 €/Monat, Growth ab 199 €/Monat, Pro ab 499 €/Monat) schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei.",
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis globaler Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.",
"landing_feature_1_body": "Jede Kosten-, Erlös- und Finanzierungsannahme ist anpassbar. Nichts ist versteckt, nichts ist fest vorgegeben.",
"landing_feature_2_body": "IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Analyse — genau die Kennzahlen, die Banken und Investoren verlangen.",
"landing_feature_3_body": "Indoorhallen, Außenplätze, Miet- oder Eigentumsmodell — mit Saisonalität und regionalen Kostenanpassungen.",
"landing_feature_4_body": "Sieh, wie sich Deine Rendite verändert, wenn die Auslastung um 10 % sinkt oder die Zinsen steigen. Break-even-Punkt sofort ermitteln.",
"landing_feature_5_body": "Erhalte dasselbe Finanzmodell, das ein Berater für 5.00010.000 € berechnen würde. Jederzeit selbst aktualisierbar.",
"landing_feature_6_body": "Verschiedene Standorte, Platzzahlen und Finanzierungsstrukturen testen. Den Plan finden, der funktioniert.",
"landing_supplier_sub": "Jede Angebotsanfrage enthält Dein vollständiges Finanzmodell — Budget, Platzzahl, Zeitplan und Finanzierungsstatus. {total_suppliers}+ Anbieter aus {total_countries} Ländern.",
"landing_supplier_step_1_body": "Fülle in 2 Minuten einen Projektbrief aus. Deine Planer-Daten werden automatisch übernommen.",
"landing_supplier_step_2_body": "Wir benachrichtigen Anbieter, die zu Deinen Anforderungen, Deinem Standort und Budget passen. Keine Kaltakquise nötig.",
"landing_supplier_step_3_body": "Erhalte Angebote von passenden Anbietern. Jedes Angebot basiert auf Deinen tatsächlichen Projektdaten — keine Standardkalkulationen.",
"landing_faq_a1": "Das hängt vom Format ab. Eine typische Indoorhalle mit 68 Plätzen in einem Mietobjekt kostet 250.000500.000 €. Ein Neubau liegt bei 13 Mio. €. Outdoor-Plätze starten bei rund 150.000 € für 4 Courts. Mit Padelnomics modellierst Du Dein genaues Szenario — jede Variable ist anpassbar, und Du siehst das vollständige Finanzbild in Minuten.",
"landing_faq_a2": "Ja. Der Planer erstellt IRR, MOIC, DSCR, Break-even-Analyse und 10-Jahres-Cashflow-Projektionen — genau die Kennzahlen, die Banken und Investoren erwarten. Exportiere als professionelles PDF für Deinen Kreditantrag oder Dein Investoren-Pitch.",
"landing_faq_a3": "Das Modell verwendet reale Standardwerte auf Basis europäischer und internationaler Marktdaten. Jede Annahme ist anpassbar, damit Du Deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern — nicht nur im Best Case.",
"landing_faq_a4": "Die Standardwerte basieren auf echten Platzbaukosten, Mietpreisen und Betriebsbenchmarks aus öffentlichen Quellen und Branchendaten. Du kannst jede Annahme mit Deinen eigenen Zahlen überschreiben.",
"landing_faq_a5": "Der Planer ist 100 % kostenlos — ohne Registrierung, ohne Kreditkarte, ohne Testphase. Erstelle ein kostenloses Konto, um Szenarien zu speichern und Konfigurationen zu vergleichen. Der PDF-Export ist als Zusatzleistung verfügbar (99 € einmalig).",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot in Märkten von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Padel-Anlage zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Du als Unternehmer Deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Padel-Anlage bewertest — Padelnomics gibt Dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere Deine Investition und lass Dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
"landing_final_cta_sub": "Schließ Dich 1.000+ Padel-Unternehmern an, die aufgehört haben zu raten — und angefangen haben, mit echten Daten zu planen.",
"landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.",
"landing_proof_plans": "{count}+ Businesspläne erstellt",
"landing_proof_suppliers": "{count}+ Anbieter in {countries} Ländern",
"landing_proof_projects": "{amount} Mio. €+ an geplanten Projekten",
"landing_familiar_title": "Kommt Dir das bekannt vor?",
"landing_familiar_1_quote": "Ich denke seit Monaten darüber nach — ich muss einfach mal die Zahlen durchrechnen",
"landing_familiar_1_desc": "Der Planer macht aus Deinen Annahmen ein bankfertiges Finanzmodell — in Minuten statt Wochen.",
"landing_familiar_2_quote": "Die Bank will einen Businessplan und ich starre auf eine leere Tabelle",
"landing_familiar_2_desc": "IRR, DSCR, MOIC, Cashflow-Projektionen — alles wird automatisch aus Deinen Eingaben generiert.",
"landing_familiar_3_quote": "Ich finde widersprüchliche Kostendaten und weiß nicht, was ich glauben soll",
"landing_familiar_3_desc": "Die Standardwerte basieren auf echten Marktdaten. Passe jede Annahme an Deinen lokalen Markt an.",
"landing_familiar_4_quote": "Mein Partner ist skeptisch — ich brauche einen Beweis, dass das nicht verrückt ist",
"landing_familiar_4_desc": "Stresstest per Sensitivitätsanalyse. Zeig genau, wo der Plan bricht — und wo nicht.",
"landing_familiar_cta": "Du bist nicht allein. 1.000+ Padel-Unternehmer haben hier angefangen.",
"landing_vs_title": "Warum Padelnomics?",
"landing_vs_sub": "Du hast Alternativen. Hier der ehrliche Vergleich.",
"landing_vs_col_diy": "Eigene Tabelle",
"landing_vs_col_consultant": "Externer Berater",
"landing_vs_col_us": "Padelnomics",
"landing_vs_row1_label": "Kosten",
"landing_vs_row1_diy": "Kostenlos, dauert aber Wochen",
"landing_vs_row1_consultant": "5.00010.000 €",
"landing_vs_row1_us": "Kostenlos, sofort",
"landing_vs_row2_label": "Qualität",
"landing_vs_row2_diy": "Wirkt unprofessionell bei Banken",
"landing_vs_row2_consultant": "Professionell, aber statisch",
"landing_vs_row2_us": "Professionell, anpassbar",
"landing_vs_row3_label": "Daten",
"landing_vs_row3_diy": "Keine Markt-Benchmarks",
"landing_vs_row3_consultant": "Generisch, nicht padelspezifisch",
"landing_vs_row3_us": "Echte Padel-Marktdaten",
"landing_vs_diy_cta": "Du verdienst Besseres als Raten",
"landing_vs_consultant_cta": "Du verdienst Besseres als 5.000 € zu zahlen",
"landing_vs_us_cta": "Kostenlos starten →",
"plan_basic_f1": "Verifiziert-Badge",
"plan_basic_f2": "Firmenlogo",
"plan_basic_f3": "Vollständige Beschreibung & Slogan",
@@ -1736,7 +1769,14 @@
"sup_guarantee_badge": "Garantie ohne Risiko",
"sup_leads_section_h2": "So sehen deine Interessenten aus",
"sup_leads_section_sub": "Jeder Lead hat unseren Finanzplaner genutzt. Kontaktdaten werden nach dem Freischalten sichtbar.",
"sup_roi_line": "Ein einziges 4-Court-Projekt = <strong>€30.000+ Gewinn</strong>. Growth-Plan: €2.388/Jahr. Die Rechnung ist einfach.",
"sup_roi_line": "Dein durchschnittliches Projekt ist <strong>€50.000+</strong> wert. Wenn wir dir 5 qualifizierte Leads/Monat schicken und du 1 abschließt, sind das €50.000 Umsatz für €199/Monat. Die Rechnung ist einfach.",
"sup_familiar_title": "Kommt dir das bekannt vor?",
"sup_familiar_1_quote": "20 Angebote letztes Quartal. 3 Abschlüsse.",
"sup_familiar_1_pivot": "Schluss mit Angeboten an Interessenten, die nie ernst gemeint haben.",
"sup_familiar_2_quote": "Pipeline für Q3 sieht dünn aus — dabei haben wir Kapazität.",
"sup_familiar_2_pivot": "Ein planbarer Lead-Strom, auf den du dich verlassen kannst.",
"sup_familiar_3_quote": "Schon wieder hat ein Wettbewerber uns beim Preis unterboten.",
"sup_familiar_3_pivot": "Gewinn über Angebotsqualität, nicht über den Preis.",
"sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.",
"sup_credits_only_cta": "Credits kaufen →",
"sup_step1_free_forever": "Dauerhaft kostenlos",

View File

@@ -89,17 +89,17 @@
"flash_verify_invalid": "Invalid verification link.",
"flash_verify_expired": "This link has expired or already been used. Please submit a new quote request.",
"flash_verify_invalid_lead": "This quote has already been verified or does not exist.",
"landing_hero_badge": "Padel court financial planner",
"landing_hero_h1_1": "Plan Your Padel",
"landing_hero_h1_2": "Business in Minutes,",
"landing_hero_h1_3": "Not Months",
"landing_hero_btn_primary": "Plan Your Padel Business →",
"landing_hero_btn_secondary": "Browse Suppliers",
"landing_hero_bullet_1": "No signup required",
"landing_hero_bullet_2": "60+ variables",
"landing_hero_bullet_3": "Unlimited scenarios",
"landing_roi_title": "Quick ROI Estimate",
"landing_roi_subtitle": "Drag the sliders to see your projection",
"landing_hero_badge": "The padel startup toolkit — free",
"landing_hero_h1_1": "Invest in Padel",
"landing_hero_h1_2": "with Confidence,",
"landing_hero_h1_3": "Not Guesswork",
"landing_hero_btn_primary": "Start Your Free Business Plan →",
"landing_hero_btn_secondary": "Get Supplier Quotes",
"landing_hero_bullet_1": "Free — no signup, no credit card",
"landing_hero_bullet_2": "Bank-ready metrics (IRR, DSCR, MOIC)",
"landing_hero_bullet_3": "Based on real market data",
"landing_roi_title": "Is your padel idea profitable?",
"landing_roi_subtitle": "Find out in 30 seconds",
"landing_roi_courts": "Courts",
"landing_roi_rate": "Avg. Hourly Rate",
"landing_roi_util": "Target Utilization",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Payback Period",
"landing_roi_annual_roi": "Annual ROI",
"landing_roi_note": "Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.",
"landing_roi_cta": "Plan Your Padel Business →",
"landing_roi_cta": "Build Your Full Business Plan — Free →",
"landing_journey_title": "Your Journey",
"landing_journey_01": "Explore",
"landing_journey_01_badge": "Soon",
@@ -118,27 +118,27 @@
"landing_journey_04": "Build",
"landing_journey_05": "Grow",
"landing_journey_05_badge": "Soon",
"landing_features_title": "Built for Serious Padel Entrepreneurs",
"landing_feature_1_h3": "60+ Variables",
"landing_feature_2_h3": "6 Analysis Tabs",
"landing_feature_3_h3": "Indoor & Outdoor",
"landing_feature_4_h3": "Sensitivity Analysis",
"landing_feature_5_h3": "Professional Metrics",
"landing_feature_6_h3": "Save & Compare",
"landing_supplier_title": "Find the Right Suppliers for Your Project",
"landing_supplier_step_1_title": "Plan Your Venue",
"landing_supplier_step_2_title": "Get Quotes",
"landing_supplier_step_3_title": "Compare & Build",
"landing_supplier_browse_btn": "Browse Supplier Directory",
"landing_features_title": "Everything You Need to Make a Confident Decision",
"landing_feature_1_h3": "Know Your Numbers Inside Out",
"landing_feature_2_h3": "Bank-Ready from Day One",
"landing_feature_3_h3": "Any Venue Type, Any Market",
"landing_feature_4_h3": "Stress-Test Before You Commit",
"landing_feature_5_h3": "Replace the €5K Consultant",
"landing_feature_6_h3": "Compare Scenarios Side by Side",
"landing_supplier_title": "Ready to Build? Get Matched with Verified Suppliers",
"landing_supplier_step_1_title": "Share Your Project",
"landing_supplier_step_2_title": "Get Matched",
"landing_supplier_step_3_title": "Compare Proposals",
"landing_supplier_browse_btn": "Get Quotes — Free & No Obligation",
"landing_faq_title": "Frequently Asked Questions",
"landing_faq_q1": "What does the planner calculate?",
"landing_faq_q2": "Do I need to sign up?",
"landing_faq_q3": "How does supplier matching work?",
"landing_faq_q4": "Is the supplier directory free?",
"landing_faq_q5": "How accurate are the financial projections?",
"landing_faq_q1": "How much does it cost to open a padel facility?",
"landing_faq_q2": "Will a bank accept a Padelnomics business plan?",
"landing_faq_q3": "How accurate are the financial projections?",
"landing_faq_q4": "What data are the market benchmarks based on?",
"landing_faq_q5": "Do I need to pay anything?",
"landing_seo_title": "Padel Court Investment Planning",
"landing_final_cta_h2": "Start Planning Today",
"landing_final_cta_btn": "Plan Your Padel Business →",
"landing_final_cta_h2": "Your Bank Meeting Is Coming. Be Ready.",
"landing_final_cta_btn": "Start Your Free Business Plan →",
"features_h1": "Everything You Need to Plan Your Padel Business",
"features_subtitle": "Professional-grade financial modeling, completely free.",
"features_card_1_h2": "60+ Variables",
@@ -891,7 +891,7 @@
"sup_meta_desc": "Free directory listing on Padelnomics. Qualified leads from buyers with business plans. Growth and Pro plans from €199/mo.",
"sup_hero_h1a": "Stop Chasing Cold Leads.",
"sup_hero_h1b": "Meet Buyers Who Already Have a Business Plan.",
"sup_hero_sub": "Every lead on Padelnomics has modeled their CAPEX, projected revenue, and calculated ROI — before they contact you. No tire-kickers. No “just browsing.”",
"sup_hero_sub": "Every lead has already built a financial model for their project. You get the budget, timeline, and specs — before you make first contact.",
"sup_hero_cta": "Get Started Free",
"sup_hero_trust_pre": "Trusted by suppliers in",
"sup_hero_trust_post": "countries",
@@ -955,7 +955,7 @@
"sup_basic_f4": "Website & contact details",
"sup_basic_f5": "Services offered checklist",
"sup_basic_f6": "Enquiry form on listing page",
"sup_basic_cta": "List Your Company Free",
"sup_basic_cta": "Get Listed Free",
"sup_growth_name": "Growth",
"sup_growth_popular": "Most Popular",
"sup_growth_credits": "30 credits/mo included",
@@ -965,7 +965,7 @@
"sup_growth_f4": "Priority over free listings",
"sup_growth_f5": "30 lead credits per month",
"sup_growth_f6": "Buy additional credit packs",
"sup_growth_cta": "Get Started",
"sup_growth_cta": "Start Getting Leads",
"sup_pro_name": "Pro",
"sup_pro_credits": "100 credits/mo included",
"sup_pro_f1": "Everything in Growth",
@@ -974,7 +974,7 @@
"sup_pro_f4": "Featured card border & glow",
"sup_pro_f5": "Priority placement in directory",
"sup_pro_f6": "100 lead credits per month",
"sup_pro_cta": "Get Started",
"sup_pro_cta": "Maximize Your Pipeline",
"sup_yearly_note_basic": "Free forever",
"sup_yearly_note_growth": "€1,799 billed yearly",
"sup_yearly_note_pro": "€4,499 billed yearly",
@@ -1012,14 +1012,14 @@
"sup_cmp_t4": "Never",
"sup_cmp_m1": "Filtered by category",
"sup_cmp_footnote": "*Google Ads estimate based on €2080 CPC for padel construction keywords at 510 clicks/day.",
"sup_proof_h2": "Trusted by Padel Industry Leaders",
"sup_proof_h2": "What You Get with Every Lead",
"sup_proof_stat1": "business plans created",
"sup_proof_stat2": "suppliers",
"sup_proof_stat3": "countries",
"sup_proof_q1": "Padelnomics sends us leads that are already serious about building. The project briefs are more detailed than what we get from trade shows.",
"sup_proof_cite1": "— European padel court manufacturer",
"sup_proof_q2": "Finally a platform that understands the padel construction market. We know the budget, the timeline, and the venue type before we even make first contact.",
"sup_proof_cite2": "— Padel court installation company, Scandinavia",
"sup_proof_point1_h3": "Complete Project Brief",
"sup_proof_point1_p": "Venue type, court count, glass/lighting specs, budget, timeline, financing status, and full contact details — before you make first contact.",
"sup_proof_point2_h3": "Financial Model Included",
"sup_proof_point2_p": "Every lead has already modeled CAPEX, revenue projections, and ROI. You're talking to someone who knows their numbers.",
"sup_faq_h2": "Supplier FAQ",
"sup_faq_q1": "How do I get listed?",
"sup_faq_a1_pre": "Find your company in our",
@@ -1055,7 +1055,14 @@
"sup_guarantee_badge": "No-risk guarantee",
"sup_leads_section_h2": "See What Your Prospects Look Like",
"sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.",
"sup_roi_line": "A single 4-court project = <strong>€30,000+ in profit</strong>. Growth plan costs €2,388/year. The math is simple.",
"sup_roi_line": "Your average project is worth <strong>€50K+</strong>. If we send you 5 qualified leads/month and you close 1, that's €50K in revenue for €199/mo. The math is simple.",
"sup_familiar_title": "Is This Your Sales Team Right Now?",
"sup_familiar_1_quote": "We quoted 20 projects last quarter. Closed 3.",
"sup_familiar_1_pivot": "Stop wasting proposals on tire-kickers.",
"sup_familiar_2_quote": "Pipeline looks thin for Q3 — but we have capacity.",
"sup_familiar_2_pivot": "A predictable lead flow you can plan around.",
"sup_familiar_3_quote": "Another competitor just undercut us on price.",
"sup_familiar_3_pivot": "Win on proposal quality, not price.",
"sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.",
"sup_credits_only_cta": "Buy Credits →",
"sup_step1_free_forever": "Free forever",
@@ -1193,34 +1200,67 @@
"features_opex_body": "Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.",
"features_cf_body": "10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.",
"features_returns_body": "Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.",
"landing_page_title": "Padelnomics - Padel Court Business Plan & ROI Calculator",
"landing_meta_desc": "Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.",
"landing_og_desc": "The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.",
"landing_hero_desc": "Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections. Then get matched with verified suppliers.",
"landing_page_title": "Padelnomics Padel Business Plan & ROI Calculator | Free",
"landing_meta_desc": "Plan your padel facility investment with real market data. Bank-ready financial model with IRR, DSCR, sensitivity analysis. Free — no signup required.",
"landing_og_desc": "Plan your padel facility investment with confidence. Bank-ready financial model, real market data, and verified supplier connections. Free — no signup required.",
"landing_hero_desc": "You're about to commit €200K+. Padelnomics gives you the financial model, market data, and supplier connections to make that decision with your eyes wide open.",
"landing_journey_01_desc": "Market demand analysis, whitespace mapping, location scoring.",
"landing_journey_02_desc": "Model your investment with 60+ variables, charts, and sensitivity analysis.",
"landing_journey_03_desc": "Connect with banks and investors. Your planner becomes your business case.",
"landing_journey_04_desc": "Browse {total_suppliers}+ court suppliers across {total_countries} countries. Get matched to your specs.",
"landing_journey_05_desc": "Launch playbook, performance benchmarks, and expansion analytics.",
"landing_feature_1_body": "Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.",
"landing_feature_2_body": "Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each with interactive charts.",
"landing_feature_3_body": "Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.",
"landing_feature_4_body": "See how your returns change with different utilization rates and pricing. Find your break-even point instantly.",
"landing_feature_5_body": "IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.",
"landing_feature_6_body": "Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.",
"landing_supplier_sub": "{total_suppliers}+ verified suppliers across {total_countries} countries. Manufacturers, builders, turf, lighting, and more.",
"landing_supplier_step_1_body": "Use the financial planner to model your courts, budget, and timeline.",
"landing_supplier_step_2_body": "Request quotes and we match you with suppliers based on your project specs.",
"landing_supplier_step_3_body": "Receive proposals from matched suppliers. No cold outreach needed.",
"landing_faq_a1": "The planner produces a complete financial model: CAPEX breakdown, monthly operating costs, cash flow projections, debt service, IRR, MOIC, DSCR, payback period, break-even utilization, and sensitivity analysis. It covers indoor/outdoor, rent/buy, and all major cost and revenue variables.",
"landing_faq_a2": "No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.",
"landing_faq_a3": "When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.",
"landing_faq_a4": "Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Basic at €39/mo, Growth at €199/mo, Pro at €499/mo) unlock enquiry forms, full descriptions, logos, verified badges, and priority placement.",
"landing_faq_a5": "The model uses real-world defaults based on global market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.",
"landing_feature_1_body": "Every cost, revenue, and financing assumption is adjustable. Nothing is hidden, nothing is hard-coded.",
"landing_feature_2_body": "IRR, MOIC, DSCR, cash-on-cash yield, break-even analysis — the exact metrics banks and investors ask for.",
"landing_feature_3_body": "Indoor halls, outdoor courts, rent or build — with seasonality and regional cost adjustments built in.",
"landing_feature_4_body": "See how your returns change when utilization drops 10% or interest rates rise. Find your break-even point instantly.",
"landing_feature_5_body": "Get the same financial model a consulting firm would charge €5,00010,000 for. Update it yourself, anytime.",
"landing_feature_6_body": "Test different locations, court counts, and financing structures. Find the plan that works.",
"landing_supplier_sub": "Every quote request includes your full financial model — budget, court count, timeline, and financing status. {total_suppliers}+ suppliers across {total_countries} countries.",
"landing_supplier_step_1_body": "Complete a 2-minute project brief. Your planner scenario data is included automatically.",
"landing_supplier_step_2_body": "We notify suppliers who match your specs, location, and budget. No cold outreach needed.",
"landing_supplier_step_3_body": "Receive quotes from matched suppliers. Every proposal is based on your actual project data — no generic estimates.",
"landing_faq_a1": "It depends on the format. A typical indoor padel venue with 68 courts in a rented building costs €250K500K. Building your own hall pushes that to €13M. Outdoor courts start around €150K for 4 courts. Padelnomics lets you model your exact scenario — adjust every variable and see the full financial picture in minutes.",
"landing_faq_a2": "Yes. The planner produces IRR, MOIC, DSCR, break-even analysis, and 10-year cash flow projections — the exact metrics banks and investors expect. Export as a professional PDF to include in your loan application or investor pitch.",
"landing_faq_a3": "The model uses real-world defaults based on market data across Europe and beyond. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, so you understand the full range of outcomes — not just the best case.",
"landing_faq_a4": "Default values are derived from real court construction costs, rental rates, and operating benchmarks gathered from public sources and industry data. You can override any assumption with your own numbers.",
"landing_faq_a5": "The planner is 100% free — no signup, no credit card, no trial period. Create a free account to save scenarios and compare configurations. PDF export is available as a paid add-on (€99 one-time).",
"landing_seo_p1": "Padel is one of the fastest-growing racket sports globally, with demand for courts outstripping supply across markets from Germany, Spain, and Sweden to the US and Middle East. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between €300K (renting an existing building) and €2-3M (building new), with payback periods of 3-5 years for well-located venues.",
"landing_seo_p2": "The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.",
"landing_final_cta_sub": "Model your investment, then get matched with verified court suppliers across {total_countries} countries.",
"landing_final_cta_sub": "Join 1,000+ padel entrepreneurs who stopped guessing and started planning with real data.",
"landing_jsonld_org_desc": "Professional padel court investment planning platform. Financial planner, supplier directory, and market intelligence for padel entrepreneurs.",
"landing_proof_plans": "{count}+ business plans created",
"landing_proof_suppliers": "{count}+ suppliers in {countries} countries",
"landing_proof_projects": "€{amount}M+ in projects planned",
"landing_familiar_title": "Sound Familiar?",
"landing_familiar_1_quote": "I've been thinking about this for months — I just need to run the numbers",
"landing_familiar_1_desc": "The planner turns your assumptions into a bank-ready financial model in minutes, not weeks.",
"landing_familiar_2_quote": "The bank asked for a business plan and I'm staring at a blank spreadsheet",
"landing_familiar_2_desc": "IRR, DSCR, MOIC, cash flow projections — all generated automatically from your inputs.",
"landing_familiar_3_quote": "I found conflicting cost data and I don't know what to trust",
"landing_familiar_3_desc": "Default values are based on real market data. Adjust any assumption to match your local market.",
"landing_familiar_4_quote": "My partner is skeptical — I need proof this isn't crazy",
"landing_familiar_4_desc": "Stress-test your plan with sensitivity analysis. Show exactly where it breaks — and where it doesn't.",
"landing_familiar_cta": "You're not alone. 1,000+ padel entrepreneurs started here.",
"landing_vs_title": "Why Padelnomics?",
"landing_vs_sub": "You have options. Here's the honest comparison.",
"landing_vs_col_diy": "DIY Spreadsheet",
"landing_vs_col_consultant": "Hired Consultant",
"landing_vs_col_us": "Padelnomics",
"landing_vs_row1_label": "Cost",
"landing_vs_row1_diy": "Free but takes weeks",
"landing_vs_row1_consultant": "€5,00010,000",
"landing_vs_row1_us": "Free, instant",
"landing_vs_row2_label": "Quality",
"landing_vs_row2_diy": "Looks amateur to banks",
"landing_vs_row2_consultant": "Professional but static",
"landing_vs_row2_us": "Professional, adjustable",
"landing_vs_row3_label": "Data",
"landing_vs_row3_diy": "No market benchmarks",
"landing_vs_row3_consultant": "Generic, not padel-specific",
"landing_vs_row3_us": "Real padel market data",
"landing_vs_diy_cta": "You deserve better than guessing",
"landing_vs_consultant_cta": "You deserve better than paying €5K",
"landing_vs_us_cta": "Start free →",
"plan_basic_f1": "Verified badge",
"plan_basic_f2": "Company logo",
"plan_basic_f3": "Full description & tagline",

View File

@@ -0,0 +1,33 @@
"""Migration 0028: Generalize paddle_products → payment_products.
New table supports multiple payment providers (paddle, stripe).
Existing paddle_products rows are copied with provider='paddle'.
The old paddle_products table is kept (no drop) for backwards compatibility.
"""
def up(conn) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
key TEXT NOT NULL,
provider_product_id TEXT NOT NULL,
provider_price_id TEXT NOT NULL,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
billing_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(provider, key)
)
""")
# Copy existing paddle_products rows
conn.execute("""
INSERT OR IGNORE INTO payment_products
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type, created_at)
SELECT
'paddle', key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type, created_at
FROM paddle_products
""")

View File

@@ -18,7 +18,7 @@ from ..core import (
feature_gate,
fetch_all,
fetch_one,
get_paddle_price,
get_price_id,
utcnow_iso,
)
from ..i18n import get_translations
@@ -687,7 +687,9 @@ async def export_details():
@login_required
@csrf_protect
async def export_checkout():
"""Return JSON for Paddle.js overlay checkout for business plan PDF."""
"""Return checkout JSON for business plan PDF (works with Paddle overlay or Stripe redirect)."""
from ..billing.routes import _provider
form = await request.form
scenario_id = form.get("scenario_id")
language = form.get("language", "en")
@@ -703,23 +705,20 @@ async def export_checkout():
if not scenario:
return jsonify({"error": "Scenario not found."}), 404
price_id = await get_paddle_price("business_plan")
price_id = await get_price_id("business_plan")
if not price_id:
return jsonify({"error": "Product not configured. Contact support."}), 500
return jsonify(
{
"items": [{"priceId": price_id, "quantity": 1}],
"customData": {
"user_id": str(g.user["id"]),
"scenario_id": str(scenario_id),
"language": language,
},
"settings": {
"successUrl": f"{config.BASE_URL}/planner/export/success",
},
}
payload = _provider().build_checkout_payload(
price_id=price_id,
custom_data={
"user_id": str(g.user["id"]),
"scenario_id": str(scenario_id),
"language": language,
},
success_url=f"{config.BASE_URL}/planner/export/success",
)
return jsonify(payload)
@bp.route("/export/success")

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %}
<style>
@@ -133,11 +133,7 @@ document.getElementById('export-form').addEventListener('submit', async function
btn.textContent = '{{ t.export_btn }}';
return;
}
Paddle.Checkout.open({
items: data.items,
customData: data.customData,
settings: data.settings,
});
startCheckout(data);
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
} catch (err) {

View File

@@ -26,10 +26,21 @@ async def _supplier_counts():
@bp.route("/")
async def landing():
total_suppliers, total_countries = await _supplier_counts()
calc_requests = await count_where("scenarios WHERE deleted_at IS NULL")
budget_row = await fetch_one(
"SELECT COALESCE(SUM(budget_estimate), 0) AS total"
" FROM lead_requests WHERE budget_estimate > 0 AND lead_type = 'quote'"
)
total_budget_millions = round((budget_row["total"] if budget_row else 0) / 1_000_000, 1)
# Floor to nearest whole number if > 1, show one decimal if < 1
if total_budget_millions >= 1:
total_budget_millions = int(total_budget_millions)
return await render_template(
"landing.html",
total_suppliers=total_suppliers,
total_countries=total_countries,
calc_requests=calc_requests,
total_budget_millions=total_budget_millions,
)

View File

@@ -118,48 +118,64 @@
}
.roi-calc__cta:hover { background: #1E40AF; color: #fff; }
/* Journey timeline */
.journey-section { padding: 5rem 0 4rem; }
.journey-section h2 { text-align: center; font-size: 1.75rem; margin-bottom: 3.5rem; }
.journey-track {
display: grid; grid-template-columns: repeat(5, 1fr);
position: relative; padding: 0 1rem;
/* Proof strip */
.proof-strip {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 1rem 1.5rem; font-size: 0.8125rem; color: #64748B;
border-bottom: 1px solid #E2E8F0; flex-wrap: wrap;
}
.journey-track::after {
content: ''; position: absolute; top: 23px; left: 12%; right: 12%;
height: 2px; background: #E2E8F0; z-index: 0;
.proof-strip__dot { color: #CBD5E1; }
/* "Sound familiar?" cards */
.familiar-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem;
max-width: 800px; margin: 0 auto;
}
.journey-step {
display: flex; flex-direction: column; align-items: center;
text-align: center; position: relative; z-index: 1;
.familiar-card {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 1.5rem; position: relative;
}
.journey-step__num {
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-weight: 800; font-size: 0.875rem;
background: #fff; border: 2px solid #E2E8F0; color: #CBD5E1;
margin-bottom: 1rem; transition: all 0.2s;
.familiar-card blockquote {
font-size: 0.9375rem; color: #334155; font-style: italic;
line-height: 1.6; margin: 0 0 0.75rem; padding: 0;
}
.journey-step--active .journey-step__num {
background: #1D4ED8; border-color: #1D4ED8; color: #fff;
box-shadow: 0 4px 16px rgba(29,78,216,0.3);
.familiar-card p {
font-size: 0.8125rem; color: #64748B; margin: 0; line-height: 1.5;
}
.journey-step__title {
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-size: 0.9375rem; font-weight: 700; color: #0F172A; margin-bottom: 0.375rem;
/* "Why Padelnomics" comparison */
.vs-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem;
max-width: 900px; margin: 0 auto;
}
.journey-step__desc {
font-size: 0.8125rem; color: #64748B; max-width: 170px; line-height: 1.5;
.vs-card {
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem;
display: flex; flex-direction: column;
}
.journey-step--upcoming { opacity: 0.45; }
.journey-step--upcoming .journey-step__title { color: #64748B; }
.badge-soon {
display: inline-block; background: rgba(29,78,216,0.08); color: #1D4ED8;
font-size: 0.625rem; font-weight: 700; padding: 2px 8px; border-radius: 999px;
margin-left: 4px; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle;
.vs-card h3 {
font-size: 1rem; margin-bottom: 1rem; text-align: center;
}
.vs-card dl { flex-grow: 1; }
.vs-card dt {
font-size: 0.6875rem; color: #94A3B8; text-transform: uppercase;
letter-spacing: 0.04em; margin-top: 0.75rem;
}
.vs-card dt:first-of-type { margin-top: 0; }
.vs-card dd { font-size: 0.875rem; color: #475569; margin: 0.25rem 0 0; }
.vs-card--muted { background: #F8FAFC; }
.vs-card--highlight {
border-color: #1D4ED8; border-width: 2px;
box-shadow: 0 4px 16px rgba(29,78,216,0.08);
}
.vs-card__bottom {
font-size: 0.8125rem; color: #94A3B8; font-style: italic;
text-align: center; margin-top: auto; padding-top: 1rem;
}
.vs-card__cta {
display: block; text-align: center; margin-top: auto; padding-top: 1rem;
color: #1D4ED8; font-weight: 600; font-size: 0.875rem; text-decoration: none;
}
.vs-card__cta:hover { text-decoration: underline; }
/* Supplier matching */
.match-grid {
@@ -225,14 +241,8 @@
.hero-title { font-size: clamp(32px, 8vw, 44px); }
.hero-bullets { flex-wrap: wrap; gap: 12px; }
.roi-metrics { grid-template-columns: 1fr 1fr; }
.journey-track { grid-template-columns: 1fr; gap: 2rem; padding: 0; }
.journey-track::after { display: none; }
.journey-step {
display: grid; grid-template-columns: 48px 1fr;
column-gap: 1rem; text-align: left; align-items: start;
}
.journey-step__num { grid-row: 1 / 3; margin-bottom: 0; }
.journey-step__desc { max-width: none; }
.familiar-grid { grid-template-columns: 1fr; }
.vs-grid { grid-template-columns: 1fr; }
.match-grid { grid-template-columns: 1fr; }
}
</style>
@@ -253,7 +263,7 @@
<p class="hero-desc">{{ t.landing_hero_desc }}</p>
<div class="hero-actions">
<a href="{{ url_for('planner.index') }}" class="btn-hero">{{ t.landing_hero_btn_primary }}</a>
<a href="{{ url_for('directory.index') }}" class="btn-hero-outline">{{ t.landing_hero_btn_secondary }}</a>
<a href="{{ url_for('quote.wizard') }}" class="btn-hero-outline">{{ t.landing_hero_btn_secondary }}</a>
</div>
<div class="hero-bullets">
<span><span class="hero-check">&#x2713;</span> {{ t.landing_hero_bullet_1 }}</span>
@@ -305,36 +315,37 @@
</section>
<main class="container-page">
<!-- Journey Timeline -->
<section class="journey-section">
<h2>{{ t.landing_journey_title }}</h2>
<div class="journey-track">
<div class="journey-step journey-step--upcoming">
<div class="journey-step__num">01</div>
<h3 class="journey-step__title">{{ t.landing_journey_01 }} <span class="badge-soon">{{ t.landing_journey_01_badge }}</span></h3>
<p class="journey-step__desc">{{ t.landing_journey_01_desc }}</p>
<!-- Social proof strip -->
<div class="proof-strip">
<span>{{ t.landing_proof_plans | tformat(count=calc_requests) }}</span>
<span class="proof-strip__dot">&middot;</span>
<span>{{ t.landing_proof_suppliers | tformat(count=total_suppliers, countries=total_countries) }}</span>
<span class="proof-strip__dot">&middot;</span>
<span>{{ t.landing_proof_projects | tformat(amount=total_budget_millions) }}</span>
</div>
<!-- "Sound familiar?" — struggling moments -->
<section class="py-12">
<h2 class="text-2xl text-center mb-8">{{ t.landing_familiar_title }}</h2>
<div class="familiar-grid">
<div class="familiar-card">
<blockquote>&ldquo;{{ t.landing_familiar_1_quote }}&rdquo;</blockquote>
<p>{{ t.landing_familiar_1_desc }}</p>
</div>
<div class="journey-step journey-step--active">
<div class="journey-step__num">02</div>
<h3 class="journey-step__title">{{ t.landing_journey_02 }}</h3>
<p class="journey-step__desc">{{ t.landing_journey_02_desc }}</p>
<div class="familiar-card">
<blockquote>&ldquo;{{ t.landing_familiar_2_quote }}&rdquo;</blockquote>
<p>{{ t.landing_familiar_2_desc }}</p>
</div>
<div class="journey-step journey-step--upcoming">
<div class="journey-step__num">03</div>
<h3 class="journey-step__title">{{ t.landing_journey_03 }} <span class="badge-soon">{{ t.landing_journey_03_badge }}</span></h3>
<p class="journey-step__desc">{{ t.landing_journey_03_desc }}</p>
<div class="familiar-card">
<blockquote>&ldquo;{{ t.landing_familiar_3_quote }}&rdquo;</blockquote>
<p>{{ t.landing_familiar_3_desc }}</p>
</div>
<div class="journey-step journey-step--active">
<div class="journey-step__num">04</div>
<h3 class="journey-step__title">{{ t.landing_journey_04 }}</h3>
<p class="journey-step__desc">{{ t.landing_journey_04_desc | tformat(total_suppliers=total_suppliers, total_countries=total_countries) }}</p>
</div>
<div class="journey-step journey-step--upcoming">
<div class="journey-step__num">05</div>
<h3 class="journey-step__title">{{ t.landing_journey_05 }} <span class="badge-soon">{{ t.landing_journey_05_badge }}</span></h3>
<p class="journey-step__desc">{{ t.landing_journey_05_desc }}</p>
<div class="familiar-card">
<blockquote>&ldquo;{{ t.landing_familiar_4_quote }}&rdquo;</blockquote>
<p>{{ t.landing_familiar_4_desc }}</p>
</div>
</div>
<p class="text-center text-slate mt-6" style="font-weight:500">{{ t.landing_familiar_cta }}</p>
</section>
<!-- Feature Highlights -->
@@ -370,6 +381,41 @@
</div>
</section>
<!-- "Why Padelnomics" comparison -->
<section class="py-12">
<h2 class="text-2xl text-center mb-2">{{ t.landing_vs_title }}</h2>
<p class="text-center text-slate mb-8">{{ t.landing_vs_sub }}</p>
<div class="vs-grid">
<div class="vs-card vs-card--muted">
<h3>{{ t.landing_vs_col_diy }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_diy }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_diy }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_diy }}</dd>
</dl>
<p class="vs-card__bottom">{{ t.landing_vs_diy_cta }}</p>
</div>
<div class="vs-card vs-card--muted">
<h3>{{ t.landing_vs_col_consultant }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_consultant }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_consultant }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_consultant }}</dd>
</dl>
<p class="vs-card__bottom">{{ t.landing_vs_consultant_cta }}</p>
</div>
<div class="vs-card vs-card--highlight">
<h3>{{ t.landing_vs_col_us }}</h3>
<dl>
<dt>{{ t.landing_vs_row1_label }}</dt><dd>{{ t.landing_vs_row1_us }}</dd>
<dt>{{ t.landing_vs_row2_label }}</dt><dd>{{ t.landing_vs_row2_us }}</dd>
<dt>{{ t.landing_vs_row3_label }}</dt><dd>{{ t.landing_vs_row3_us }}</dd>
</dl>
<a href="{{ url_for('planner.index') }}" class="vs-card__cta">{{ t.landing_vs_us_cta }}</a>
</div>
</div>
</section>
<!-- Supplier Matching -->
<section class="py-12">
<h2 class="text-2xl text-center mb-2">{{ t.landing_supplier_title }}</h2>
@@ -392,7 +438,7 @@
</div>
</div>
<div class="text-center mt-8">
<a href="{{ url_for('directory.index') }}" class="btn-outline">{{ t.landing_supplier_browse_btn }}</a>
<a href="{{ url_for('quote.wizard') }}" class="btn-outline">{{ t.landing_supplier_browse_btn }}</a>
</div>
</section>
@@ -436,7 +482,7 @@
<section style="padding: 2rem 0 4rem">
<div class="cta-card">
<h2>{{ t.landing_final_cta_h2 }}</h2>
<p>{{ t.landing_final_cta_sub | tformat(total_countries=total_countries) }}</p>
<p>{{ t.landing_final_cta_sub }}</p>
<a href="{{ url_for('planner.index') }}" class="cta-card__btn">{{ t.landing_final_cta_btn }}</a>
</div>
</section>

View File

@@ -256,9 +256,26 @@
.sup-cta h2 { font-size: 1.75rem; margin-bottom: 0.75rem; color: white; }
.sup-cta p { color: #94A3B8; margin-bottom: 1.5rem; font-size: 1rem; }
/* Supplier struggling moments */
.sup-familiar-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem;
max-width: 900px; margin: 0 auto;
}
.sup-familiar-card {
background: #FEF2F2; border: 1px solid #FECACA; border-radius: 14px;
padding: 1.5rem; text-align: center;
}
.sup-familiar-card blockquote {
font-size: 0.9375rem; color: #991B1B; font-style: italic;
line-height: 1.5; margin: 0 0 0.75rem; padding: 0;
}
.sup-familiar-card p {
font-size: 0.8125rem; color: #1D4ED8; font-weight: 600; margin: 0;
}
@media (max-width: 640px) {
.sup-stats { grid-template-columns: repeat(2, 1fr); }
.sup-steps, .sup-why, .sup-problem-grid, .sup-proof-grid { grid-template-columns: 1fr; }
.sup-steps, .sup-why, .sup-problem-grid, .sup-proof-grid, .sup-familiar-grid { grid-template-columns: 1fr; }
.pricing-grid { grid-template-columns: 1fr; }
.lead-preview-grid { grid-template-columns: 1fr; }
.sup-hero h1 { font-size: 1.75rem; }
@@ -277,24 +294,30 @@
<p class="sup-hero__proof">{{ t.sup_hero_trust_pre }} {{ total_countries }} {{ t.sup_hero_trust_post }}</p>
</div>
<!-- Live Stats -->
<!-- Live Stats (conditional — hide if numbers aren't meaningful) -->
<div class="sup-stats">
{% if calc_requests >= 10 %}
<div class="sup-stat-card">
<strong>{{ calc_requests }}+</strong>
<span>{{ t.sup_stat_plans }}</span>
</div>
{% endif %}
{% if avg_budget and avg_budget > 50000 %}
<div class="sup-stat-card">
<strong>{% if avg_budget %}&euro;{{ "{:,.0f}".format(avg_budget / 1000) }}K{% else %}&mdash;{% endif %}</strong>
<strong>&euro;{{ "{:,.0f}".format(avg_budget / 1000) }}K</strong>
<span>{{ t.sup_stat_avg }}</span>
</div>
{% endif %}
<div class="sup-stat-card">
<strong>{{ total_suppliers }}+</strong>
<span>{{ t.sup_stat_suppliers_pre }} {{ total_countries }} {{ t.sup_stat_suppliers_post }}</span>
</div>
{% if monthly_leads >= 3 %}
<div class="sup-stat-card">
<strong>{{ monthly_leads }}</strong>
<span>{{ t.sup_stat_leads }}</span>
</div>
{% endif %}
</div>
<!-- Problem section -->
@@ -323,6 +346,25 @@
</div>
</section>
<!-- "Is this your sales team?" — struggling moments -->
<section class="sup-section">
<h2 style="text-align:center;margin-bottom:1.5rem">{{ t.sup_familiar_title }}</h2>
<div class="sup-familiar-grid">
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_1_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_1_pivot }}</p>
</div>
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_2_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_2_pivot }}</p>
</div>
<div class="sup-familiar-card">
<blockquote>&ldquo;{{ t.sup_familiar_3_quote }}&rdquo;</blockquote>
<p>{{ t.sup_familiar_3_pivot }}</p>
</div>
</div>
</section>
<!-- Why Padelnomics — moved above lead preview -->
<section class="sup-section">
<h2>{{ t.sup_why_h2 }}</h2>
@@ -446,22 +488,27 @@
</div>
</section>
<!-- Social proof — moved before pricing -->
<!-- What you get with every lead -->
<section class="sup-section">
<h2>{{ t.sup_proof_h2 }}</h2>
<p class="sub">{{ calc_requests }}+ {{ t.sup_proof_stat1 }} &middot; {{ total_suppliers }}+ {{ t.sup_proof_stat2 }} &middot; {{ total_countries }} {{ t.sup_proof_stat3 }}</p>
<div class="sup-proof-grid">
<div class="sup-proof-card">
<blockquote>&ldquo;{{ t.sup_proof_q1 }}&rdquo;</blockquote>
<cite>{{ t.sup_proof_cite1 }}</cite>
<h3 style="font-size:1rem;margin-bottom:0.5rem">{{ t.sup_proof_point1_h3 }}</h3>
<p style="font-size:0.875rem;color:#475569;line-height:1.6;margin:0">{{ t.sup_proof_point1_p }}</p>
</div>
<div class="sup-proof-card">
<blockquote>&ldquo;{{ t.sup_proof_q2 }}&rdquo;</blockquote>
<cite>{{ t.sup_proof_cite2 }}</cite>
<h3 style="font-size:1rem;margin-bottom:0.5rem">{{ t.sup_proof_point2_h3 }}</h3>
<p style="font-size:0.875rem;color:#475569;line-height:1.6;margin:0">{{ t.sup_proof_point2_p }}</p>
</div>
</div>
</section>
<!-- ROI callout — bridge to pricing -->
<div class="sup-roi" style="margin-bottom:0">
<p>{{ t.sup_roi_line }}</p>
</div>
<!-- Pricing -->
<section id="pricing" class="sup-section">
<!-- Hidden radio inputs MUST come before the elements they control (CSS sibling selector) -->
@@ -547,11 +594,6 @@
</div>
</div>
<!-- Static ROI line -->
<div class="sup-roi">
<p>{{ t.sup_roi_line }}</p>
</div>
<!-- Credits-only callout -->
<div class="sup-credits-only">
<p>{{ t.sup_credits_only_pre }} <a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic#credits">{{ t.sup_credits_only_cta }}</a></p>

View File

@@ -0,0 +1,254 @@
"""
Create or sync Stripe products, prices, and webhook endpoint.
Prerequisites:
- Enable Stripe Tax in your Stripe Dashboard (Settings → Tax)
- Set STRIPE_SECRET_KEY in .env
Commands:
uv run python -m padelnomics.scripts.setup_stripe # create products + webhook
uv run python -m padelnomics.scripts.setup_stripe --sync # re-populate DB from existing Stripe products
"""
import logging
import os
import re
import sqlite3
import sys
from pathlib import Path
import stripe
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
if not STRIPE_SECRET_KEY:
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
logger.error("Set STRIPE_SECRET_KEY in .env first")
sys.exit(1)
stripe.api_key = STRIPE_SECRET_KEY
stripe.max_network_retries = 2
# Product definitions — same keys as setup_paddle.py.
# Prices in EUR cents, matching Paddle exactly.
PRODUCTS = [
# Supplier Growth
{
"key": "supplier_growth",
"name": "Supplier Growth",
"price": 19900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_growth_yearly",
"name": "Supplier Growth (Yearly)",
"price": 179900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Supplier Pro
{
"key": "supplier_pro",
"name": "Supplier Pro",
"price": 49900,
"currency": "eur",
"interval": "month",
"billing_type": "subscription",
},
{
"key": "supplier_pro_yearly",
"name": "Supplier Pro (Yearly)",
"price": 449900,
"currency": "eur",
"interval": "year",
"billing_type": "subscription",
},
# Boost add-ons (subscriptions)
{"key": "boost_logo", "name": "Boost: Logo", "price": 2900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_highlight", "name": "Boost: Highlight", "price": 3900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_verified", "name": "Boost: Verified Badge", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "boost_card_color", "name": "Boost: Custom Card Color", "price": 5900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
# One-time boosts
{"key": "boost_sticky_week", "name": "Boost: Sticky Top 1 Week", "price": 7900, "currency": "eur", "billing_type": "one_time"},
{"key": "boost_sticky_month", "name": "Boost: Sticky Top 1 Month", "price": 19900, "currency": "eur", "billing_type": "one_time"},
# Credit packs
{"key": "credits_25", "name": "Credit Pack 25", "price": 9900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_50", "name": "Credit Pack 50", "price": 17900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_100", "name": "Credit Pack 100", "price": 32900, "currency": "eur", "billing_type": "one_time"},
{"key": "credits_250", "name": "Credit Pack 250", "price": 74900, "currency": "eur", "billing_type": "one_time"},
# PDF product
{"key": "business_plan", "name": "Padel Business Plan (PDF)", "price": 14900, "currency": "eur", "billing_type": "one_time"},
# Planner subscriptions
{"key": "starter", "name": "Planner Starter", "price": 1900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
{"key": "pro", "name": "Planner Pro", "price": 4900, "currency": "eur", "interval": "month", "billing_type": "subscription"},
]
_PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
def _open_db():
db_path = DATABASE_PATH
if not Path(db_path).exists():
logger.error("Database not found at %s. Run migrations first.", db_path)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _write_product(conn, key, product_id, price_id, name, price_cents, billing_type):
conn.execute(
"""INSERT OR REPLACE INTO payment_products
(provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
("stripe", key, product_id, price_id, name, price_cents, "EUR", billing_type),
)
def sync(conn):
"""Fetch existing Stripe products and re-populate payment_products table."""
logger.info("Syncing products from Stripe...")
# Fetch all products (auto-paginated, max 100 per page)
products = stripe.Product.list(limit=100, active=True)
matched = 0
for product in products.auto_paging_iter():
spec = _PRODUCT_BY_NAME.get(product.name)
if not spec:
continue
# Get the first active price for this product
prices = stripe.Price.list(product=product.id, active=True, limit=1)
if not prices.data:
logger.warning(" SKIP %s: no active prices on %s", spec["key"], product.id)
continue
price = prices.data[0]
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
matched += 1
logger.info(" %s: %s / %s", spec["key"], product.id, price.id)
conn.commit()
if matched == 0:
logger.warning("No matching products found in Stripe. Run without --sync first.")
else:
logger.info("%s/%s products synced to DB", matched, len(PRODUCTS))
def create(conn):
"""Create new products and prices in Stripe, write to DB, set up webhook."""
logger.info("Creating products in Stripe...")
for spec in PRODUCTS:
product = stripe.Product.create(
name=spec["name"],
tax_code="txcd_10000000", # General — Tangible Goods (Stripe default)
)
logger.info(" Product: %s -> %s", spec["name"], product.id)
price_params = {
"product": product.id,
"unit_amount": spec["price"],
"currency": spec["currency"],
"tax_behavior": "exclusive", # Price + tax on top (EU standard)
}
if spec["billing_type"] == "subscription":
interval = spec.get("interval", "month")
price_params["recurring"] = {"interval": interval}
price = stripe.Price.create(**price_params)
logger.info(" Price: %s = %s", spec["key"], price.id)
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
conn.commit()
logger.info("All products written to DB")
# -- Webhook endpoint -------------------------------------------------------
webhook_url = f"{BASE_URL}/billing/webhook/stripe"
enabled_events = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_failed",
]
logger.info("Creating webhook endpoint...")
logger.info(" URL: %s", webhook_url)
try:
endpoint = stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=enabled_events,
)
except stripe.InvalidRequestError as exc:
logger.warning(" Webhook endpoint creation failed: %s", exc.user_message)
logger.info(" For local dev, use Stripe CLI: stripe listen --forward-to %s", webhook_url)
logger.info("Done (products created, webhook skipped).")
return
webhook_secret = endpoint.secret
logger.info(" ID: %s", endpoint.id)
logger.info(" Secret: %s", webhook_secret)
env_path = Path(".env")
env_vars = {
"STRIPE_WEBHOOK_SECRET": webhook_secret,
"STRIPE_WEBHOOK_ENDPOINT_ID": endpoint.id,
}
if env_path.exists():
env_text = env_path.read_text()
for key, value in env_vars.items():
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, env_text, flags=re.MULTILINE):
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
else:
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
env_path.write_text(env_text)
logger.info("STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID written to .env")
else:
logger.info("Add to .env:")
for key, value in env_vars.items():
logger.info(" %s=%s", key, value)
logger.info("Done. Remember to enable Stripe Tax in your Dashboard (Settings > Tax).")
def main():
conn = _open_db()
try:
if "--sync" in sys.argv:
sync(conn)
else:
create(conn)
finally:
conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -17,7 +17,8 @@ from ..core import (
feature_gate,
fetch_all,
fetch_one,
get_paddle_price,
get_all_price_ids,
get_price_id,
is_flag_enabled,
)
from ..i18n import get_translations
@@ -383,7 +384,9 @@ def _compute_order(data: dict, included_boosts: list, t: dict) -> dict:
@bp.route("/signup/checkout", methods=["POST"])
@csrf_protect
async def signup_checkout():
"""Validate form, return JSON for Paddle.js overlay checkout."""
"""Validate form, return checkout JSON (Paddle overlay or Stripe redirect)."""
from ..billing.routes import _provider
form = await request.form
accumulated = _parse_accumulated(form)
@@ -401,9 +404,9 @@ async def signup_checkout():
if period == "yearly"
else plan_info.get("paddle_key_monthly", plan)
)
plan_price_id = await get_paddle_price(price_key)
plan_price_id = await get_price_id(price_key)
if not plan_price_id:
return jsonify({"error": "Invalid plan selected. Run setup_paddle first."}), 400
return jsonify({"error": "Invalid plan selected. Run setup first."}), 400
# Build items list
items = [{"priceId": plan_price_id, "quantity": 1}]
@@ -416,14 +419,14 @@ async def signup_checkout():
for b in BOOST_OPTIONS:
if b["type"] in selected_boosts and b["type"] not in included_boosts:
price_id = await get_paddle_price(b["key"])
price_id = await get_price_id(b["key"])
if price_id:
items.append({"priceId": price_id, "quantity": 1})
# Add credit pack (one-time)
credit_pack = accumulated.get("credit_pack", "")
if credit_pack:
price_id = await get_paddle_price(credit_pack)
price_id = await get_price_id(credit_pack)
if price_id:
items.append({"priceId": price_id, "quantity": 1})
@@ -477,15 +480,12 @@ async def signup_checkout():
"plan": plan,
}
return jsonify(
{
"items": items,
"customData": custom_data,
"settings": {
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
},
}
payload = _provider().build_multi_item_checkout_payload(
items=items,
custom_data=custom_data,
success_url=f"{config.BASE_URL}/suppliers/signup/success",
)
return jsonify(payload)
@bp.route("/claim/<slug>")
@@ -1035,12 +1035,8 @@ async def dashboard_boosts():
(supplier["id"],),
)
# Resolve Paddle price IDs for buy buttons
price_ids = {}
for b in BOOST_OPTIONS:
price_ids[b["key"]] = await get_paddle_price(b["key"])
for cp in CREDIT_PACK_OPTIONS:
price_ids[cp["key"]] = await get_paddle_price(cp["key"])
# Resolve price IDs for buy buttons (from active provider)
price_ids = await get_all_price_ids()
return await render_template(
"suppliers/partials/dashboard_boosts.html",

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ t.sd_page_title }} - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %}
<style>

View File

@@ -102,7 +102,7 @@
<div class="bst-boost__price">&euro;{{ b.price }}/mo</div>
{% if price_ids.get(b.key) %}
<button type="button" class="bst-buy-btn"
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[b.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="buyItem('{{ b.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
{{ t.sd_bst_activate }}
</button>
{% else %}
@@ -125,7 +125,7 @@
<div class="bst-credit-card__price">&euro;{{ cp.price }}</div>
{% if price_ids.get(cp.key) %}
<button type="button" class="bst-buy-btn"
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[cp.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="buyItem('{{ cp.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
{{ t.sd_bst_buy }}
</button>
{% else %}
@@ -160,3 +160,27 @@
</div>
</div>
</div>
<script>
function buyItem(priceKey, customData, btn) {
var label = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/billing/checkout/item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({price_key: priceKey, custom_data: customData}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { alert(data.error); }
else { startCheckout(data); }
btn.disabled = false;
btn.textContent = label;
})
.catch(function() {
btn.disabled = false;
btn.textContent = label;
});
}
</script>

View File

@@ -124,11 +124,7 @@
btn.textContent = {{ t.sup_step4_checkout | tojson }};
return;
}
Paddle.Checkout.open({
items: result.data.items,
customData: result.data.customData,
settings: result.data.settings
});
startCheckout(result.data);
btn.disabled = false;
btn.textContent = {{ t.sup_step4_checkout | tojson }};
})

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ t.sup_signup_page_title }} - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %}
<style>

View File

@@ -0,0 +1,51 @@
{# Payment JS — conditionally loads provider SDK on checkout pages.
Include via {% block paddle %}{% include "_payment_js.html" %}{% endblock %}
Paddle: loads Paddle.js SDK + initializes overlay checkout.
Stripe: no SDK needed (server-side Checkout Session + redirect). #}
{% if config.PAYMENT_PROVIDER == "paddle" %}
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
{% if config.PADDLE_ENVIRONMENT == "sandbox" %}
Paddle.Environment.set("sandbox");
{% endif %}
{% if config.PADDLE_CLIENT_TOKEN %}
Paddle.Initialize({
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
eventCallback: function(ev) {
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
},
checkout: {
settings: {
displayMode: "overlay",
theme: "light",
locale: "en",
}
}
});
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
});
</script>
{% endif %}
<script>
/**
* startCheckout — dual-path checkout handler.
* Paddle: opens overlay with items/customData/settings from response JSON.
* Stripe: redirects to checkout_url from response JSON.
*/
function startCheckout(data) {
if (data.checkout_url) {
window.location.href = data.checkout_url;
} else if (data.items) {
Paddle.Checkout.open({
items: data.items,
customData: data.customData,
settings: data.settings,
});
}
}
</script>

View File

@@ -125,6 +125,32 @@ async def auth_client(app, test_user):
yield c
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
@pytest.fixture
def mock_send_email():
"""Patch padelnomics.worker.send_email for the duration of the test."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock:
yield mock
# ── Subscriptions ────────────────────────────────────────────
@pytest.fixture
@@ -187,8 +213,10 @@ def patch_config():
"""Set test Paddle config values."""
original_values = {}
test_values = {
"PAYMENT_PROVIDER": "paddle", # default to Paddle so mocks work
"PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"STRIPE_WEBHOOK_SECRET": "", # no Stripe in default tests
"RESEND_API_KEY": "", # never send real emails in tests
"BASE_URL": "http://localhost:5000",
"DEBUG": True,

View File

@@ -1,6 +1,7 @@
"""
Route integration tests for Paddle billing endpoints.
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
Route integration tests for billing endpoints.
Tests work with the default provider (Paddle) via dispatch layer.
Checkout returns JSON (Paddle overlay or Stripe redirect URL), manage/cancel use provider SDK.
"""
from unittest.mock import MagicMock, patch
@@ -39,7 +40,24 @@ class TestSuccessPage:
# ════════════════════════════════════════════════════════════
# Checkout (Paddle.js overlay — returns JSON)
# Helper: insert a product into both payment_products and paddle_products
# ════════════════════════════════════════════════════════════
async def _insert_test_product(db, key="starter", price_id="pri_starter_123"):
"""Insert a test product into payment_products (used by get_price_id) and paddle_products (legacy fallback)."""
await db.execute(
"INSERT INTO payment_products (provider, key, provider_product_id, provider_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("paddle", key, "pro_test", price_id, "Starter", 1900, "EUR", "subscription"),
)
await db.execute(
"INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
(key, "pro_test", price_id, "Starter", 1900, "EUR", "subscription"),
)
await db.commit()
# ════════════════════════════════════════════════════════════
# Checkout (returns JSON — Paddle overlay or Stripe redirect)
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
@@ -48,12 +66,7 @@ class TestCheckoutRoute:
assert response.status_code in (302, 303, 307)
async def test_returns_checkout_json(self, auth_client, db, test_user):
# Insert a paddle_products row so get_paddle_price() finds it
await db.execute(
"INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"),
)
await db.commit()
await _insert_test_product(db)
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
assert response.status_code == 200
@@ -89,7 +102,7 @@ class TestManageRoute:
mock_client = MagicMock()
mock_client.subscriptions.get.return_value = mock_sub
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
with patch("padelnomics.billing.paddle._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@@ -111,12 +124,27 @@ class TestCancelRoute:
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_client = MagicMock()
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
with patch("padelnomics.billing.paddle._paddle_client", return_value=mock_client):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_client.subscriptions.cancel.assert_called_once()
# ════════════════════════════════════════════════════════════
# Stripe webhook returns 404 when not configured
# ════════════════════════════════════════════════════════════
class TestStripeWebhookEndpoint:
async def test_returns_404_when_not_configured(self, client, db):
"""Stripe webhook returns 404 when STRIPE_WEBHOOK_SECRET is empty."""
response = await client.post(
"/billing/webhook/stripe",
data=b'{}',
headers={"Content-Type": "application/json"},
)
assert response.status_code == 404
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════

View File

@@ -1,5 +1,5 @@
"""
Integration tests for Paddle webhook handling.
Integration tests for Paddle webhook handling + Stripe parse_webhook unit tests.
Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing.
"""
import json
@@ -10,6 +10,7 @@ from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from padelnomics.billing.routes import get_subscription
from padelnomics.billing.stripe import parse_webhook as stripe_parse_webhook
WEBHOOK_PATH = "/billing/webhook/paddle"
SIG_HEADER = "Paddle-Signature"
@@ -306,3 +307,111 @@ class TestWebhookHypothesis:
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code < 500
# ════════════════════════════════════════════════════════════
# Stripe parse_webhook unit tests
# ════════════════════════════════════════════════════════════
def _stripe_event(event_type, obj, metadata=None):
"""Build a minimal Stripe event payload."""
if metadata:
obj["metadata"] = metadata
return json.dumps({"type": event_type, "data": {"object": obj}}).encode()
class TestStripeParseWebhook:
def test_subscription_created_maps_to_activated(self):
payload = _stripe_event(
"customer.subscription.created",
{"id": "sub_123", "customer": "cus_456", "status": "active", "current_period_end": 1740000000,
"items": {"data": [{"price": {"id": "price_abc"}}]}},
metadata={"user_id": "42", "plan": "starter"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.activated"
assert ev["subscription_id"] == "sub_123"
assert ev["customer_id"] == "cus_456"
assert ev["user_id"] == "42"
assert ev["plan"] == "starter"
assert ev["status"] == "active"
assert ev["items"] == [{"price": {"id": "price_abc"}}]
def test_subscription_updated_maps_correctly(self):
payload = _stripe_event(
"customer.subscription.updated",
{"id": "sub_123", "customer": "cus_456", "status": "past_due", "current_period_end": 1740000000,
"items": {"data": [{"price": {"id": "price_abc"}}]}},
metadata={"user_id": "42", "plan": "pro"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.updated"
assert ev["status"] == "past_due"
def test_subscription_deleted_maps_to_canceled(self):
payload = _stripe_event(
"customer.subscription.deleted",
{"id": "sub_123", "customer": "cus_456", "status": "canceled", "current_period_end": 1740000000,
"items": {"data": []}},
metadata={"user_id": "42"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.canceled"
assert ev["status"] == "cancelled"
def test_invoice_payment_failed_maps_to_past_due(self):
payload = _stripe_event(
"invoice.payment_failed",
{"subscription": "sub_123", "customer": "cus_456"},
metadata={},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.past_due"
assert ev["status"] == "past_due"
def test_checkout_session_subscription_maps_to_activated(self):
payload = _stripe_event(
"checkout.session.completed",
{"mode": "subscription", "subscription": "sub_123", "customer": "cus_456"},
metadata={"user_id": "42", "plan": "starter"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.activated"
assert ev["subscription_id"] == "sub_123"
def test_checkout_session_payment_maps_to_transaction(self):
payload = _stripe_event(
"checkout.session.completed",
{"mode": "payment", "customer": "cus_456"},
metadata={"user_id": "42"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "transaction.completed"
def test_unknown_event_returns_empty_type(self):
payload = _stripe_event("some.unknown.event", {"customer": "cus_456"})
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == ""
def test_period_end_from_items_fallback(self):
"""Stripe API 2026-02+ puts current_period_end on items, not subscription."""
payload = _stripe_event(
"customer.subscription.created",
{"id": "sub_123", "customer": "cus_456", "status": "active",
"items": {"data": [{"price": {"id": "price_abc"}, "current_period_end": 1740000000}]}},
metadata={"user_id": "42"},
)
ev = stripe_parse_webhook(payload)
assert ev["current_period_end"] is not None
assert "2025-02-19" in ev["current_period_end"]
def test_trialing_status_maps_to_on_trial(self):
payload = _stripe_event(
"customer.subscription.created",
{"id": "sub_123", "customer": "cus_456", "status": "trialing", "current_period_end": 1740000000,
"items": {"data": []}},
metadata={"user_id": "42"},
)
ev = stripe_parse_webhook(payload)
assert ev["event_type"] == "subscription.activated"
assert ev["status"] == "on_trial"

View File

@@ -9,7 +9,6 @@ Covers:
"""
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics.businessplan import generate_business_plan, get_plan_sections
@@ -184,19 +183,18 @@ async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pend
@requires_weasyprint
class TestWorkerHandler:
async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario):
async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario, mock_send_email):
from padelnomics.worker import handle_generate_business_plan
export = await _insert_export(db, scenario["user_id"], scenario["id"])
output_file = None
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email:
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": scenario["user_id"],
"scenario_id": scenario["id"],
"language": "en",
})
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": scenario["user_id"],
"scenario_id": scenario["id"],
"language": "en",
})
# Status should be 'ready'
from padelnomics.core import fetch_one
@@ -214,14 +212,14 @@ class TestWorkerHandler:
assert output_file.read_bytes()[:4] == b"%PDF"
# Email should have been sent
mock_email.assert_called_once()
assert "to" in mock_email.call_args.kwargs
assert "subject" in mock_email.call_args.kwargs
mock_send_email.assert_called_once()
assert "to" in mock_send_email.call_args.kwargs
assert "subject" in mock_send_email.call_args.kwargs
finally:
if output_file and output_file.exists():
output_file.unlink()
async def test_marks_failed_on_bad_scenario(self, db, scenario):
async def test_marks_failed_on_bad_scenario(self, db, scenario, mock_send_email):
"""Handler marks export failed when user_id doesn't match scenario owner."""
from padelnomics.worker import handle_generate_business_plan
@@ -229,14 +227,13 @@ class TestWorkerHandler:
wrong_user_id = scenario["user_id"] + 9999
export = await _insert_export(db, scenario["user_id"], scenario["id"])
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
with pytest.raises(ValueError):
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": wrong_user_id,
"scenario_id": scenario["id"],
"language": "en",
})
with pytest.raises(ValueError):
await handle_generate_business_plan({
"export_id": export["id"],
"user_id": wrong_user_id,
"scenario_id": scenario["id"],
"language": "en",
})
from padelnomics.core import fetch_one
row = await fetch_one(

View File

@@ -938,26 +938,6 @@ class TestRouteRegistration:
# Admin routes (require admin session)
# ════════════════════════════════════════════════════════════
@pytest.fixture
async def admin_client(app, db):
"""Test client with admin user (has admin role)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
class TestAdminTemplates:
async def test_template_list_requires_admin(self, client):
resp = await client.get("/admin/templates")

View File

@@ -9,7 +9,6 @@ Admin gallery tests: access control, list page, preview page, error handling.
"""
import pytest
from padelnomics.core import utcnow_iso
from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
# ── render_email_template() ──────────────────────────────────────────────────
@@ -124,26 +123,6 @@ class TestRenderEmailTemplate:
# ── Admin gallery routes ──────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Test client with a user that has the admin role."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("gallery_admin@test.com", "Gallery Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
class TestEmailGalleryRoutes:
@pytest.mark.asyncio
async def test_gallery_requires_auth(self, client):

View File

@@ -50,59 +50,51 @@ def _assert_common_design(html: str, lang: str = "en"):
class TestMagicLink:
@pytest.mark.asyncio
async def test_sends_to_correct_recipient(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert kw["to"] == "user@example.com"
async def test_sends_to_correct_recipient(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
assert kw["to"] == "user@example.com"
@pytest.mark.asyncio
async def test_subject_contains_app_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert core.config.APP_NAME.lower() in kw["subject"].lower()
async def test_subject_contains_app_name(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
assert core.config.APP_NAME.lower() in kw["subject"].lower()
@pytest.mark.asyncio
async def test_html_contains_verify_link(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert "/auth/verify?token=abc123" in kw["html"]
async def test_html_contains_verify_link(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send_email)
assert "/auth/verify?token=abc123" in kw["html"]
@pytest.mark.asyncio
async def test_html_contains_fallback_link_text(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send)["html"]
assert "word-break:break-all" in html # fallback URL block
async def test_html_contains_fallback_link_text(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send_email)["html"]
assert "word-break:break-all" in html # fallback URL block
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_preheader_mentions_expiry(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send)["html"]
# preheader is hidden span; should mention minutes
assert "display:none" in html # preheader present
async def test_preheader_mentions_expiry(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send_email)["html"]
# preheader is hidden span; should mention minutes
assert "display:none" in html # preheader present
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
_assert_common_design(_call_kwargs(mock_send)["html"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
@pytest.mark.asyncio
async def test_respects_lang_parameter(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
html = _call_kwargs(mock_send)["html"]
_assert_common_design(html, lang="de")
async def test_respects_lang_parameter(self, mock_send_email):
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
html = _call_kwargs(mock_send_email)["html"]
_assert_common_design(html, lang="de")
# ── Welcome ──────────────────────────────────────────────────────
@@ -110,59 +102,51 @@ class TestMagicLink:
class TestWelcome:
@pytest.mark.asyncio
async def test_sends_to_correct_recipient(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send)["to"] == "new@example.com"
async def test_sends_to_correct_recipient(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send_email)["to"] == "new@example.com"
@pytest.mark.asyncio
async def test_subject_not_empty(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert len(_call_kwargs(mock_send)["subject"]) > 5
async def test_subject_not_empty(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert len(_call_kwargs(mock_send_email)["subject"]) > 5
@pytest.mark.asyncio
async def test_html_contains_quickstart_links(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send)["html"]
assert "/planner" in html
assert "/markets" in html
assert "/leads/quote" in html
async def test_html_contains_quickstart_links(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send_email)["html"]
assert "/planner" in html
assert "/markets" in html
assert "/leads/quote" in html
@pytest.mark.asyncio
async def test_uses_first_name_when_provided(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
html = _call_kwargs(mock_send)["html"]
assert "Alice" in html
async def test_uses_first_name_when_provided(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
html = _call_kwargs(mock_send_email)["html"]
assert "Alice" in html
@pytest.mark.asyncio
async def test_fallback_greeting_when_no_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send)["html"]
# Should use "there" as fallback first_name
assert "there" in html.lower()
async def test_fallback_greeting_when_no_name(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send_email)["html"]
# Should use "there" as fallback first_name
assert "there" in html.lower()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
_assert_common_design(_call_kwargs(mock_send)["html"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com"})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
@pytest.mark.asyncio
async def test_german_welcome(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com", "lang": "de"})
html = _call_kwargs(mock_send)["html"]
_assert_common_design(html, lang="de")
async def test_german_welcome(self, mock_send_email):
await handle_send_welcome({"email": "new@example.com", "lang": "de"})
html = _call_kwargs(mock_send_email)["html"]
_assert_common_design(html, lang="de")
# ── Quote Verification ───────────────────────────────────────────
@@ -180,57 +164,50 @@ class TestQuoteVerification:
}
@pytest.mark.asyncio
async def test_sends_to_contact_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["to"] == "lead@example.com"
async def test_sends_to_contact_email(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["to"] == "lead@example.com"
@pytest.mark.asyncio
async def test_html_contains_verify_link(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "token=verify_tok" in html
assert "lead=lead_tok" in html
async def test_html_contains_verify_link(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
assert "token=verify_tok" in html
assert "lead=lead_tok" in html
@pytest.mark.asyncio
async def test_html_contains_project_recap(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "6 courts" in html
assert "Indoor" in html
assert "Germany" in html
async def test_html_contains_project_recap(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
assert "6 courts" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_uses_first_name_from_contact(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "Bob" in html
async def test_uses_first_name_from_contact(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
assert "Bob" in html
@pytest.mark.asyncio
async def test_handles_minimal_payload(self):
async def test_handles_minimal_payload(self, mock_send_email):
"""No court_count/facility_type/country — should still send."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification({
"email": "lead@example.com",
"token": "tok",
"lead_token": "ltok",
})
mock_send.assert_called_once()
await handle_send_quote_verification({
"email": "lead@example.com",
"token": "tok",
"lead_token": "ltok",
})
mock_send_email.assert_called_once()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send)["html"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_quote_verification(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Lead Forward (the money email) ──────────────────────────────
@@ -238,89 +215,71 @@ class TestQuoteVerification:
class TestLeadForward:
@pytest.mark.asyncio
async def test_sends_to_supplier_email(self, db):
async def test_sends_to_supplier_email(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["to"] == "supplier@test.com"
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send_email)["to"] == "supplier@test.com"
@pytest.mark.asyncio
async def test_subject_contains_heat_and_country(self, db):
async def test_subject_contains_heat_and_country(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
subject = _call_kwargs(mock_send)["subject"]
assert "[HOT]" in subject
assert "Germany" in subject
assert "4 courts" in subject
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
subject = _call_kwargs(mock_send_email)["subject"]
assert "[HOT]" in subject
assert "Germany" in subject
assert "4 courts" in subject
@pytest.mark.asyncio
async def test_html_contains_heat_badge(self, db):
async def test_html_contains_heat_badge(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
@pytest.mark.asyncio
async def test_html_contains_project_brief(self, db):
async def test_html_contains_project_brief(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "Indoor" in html
assert "Germany" in html
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_html_contains_contact_info(self, db):
async def test_html_contains_contact_info(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "lead@buyer.com" in html
assert "mailto:lead@buyer.com" in html
assert "John Doe" in html
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
assert "lead@buyer.com" in html
assert "mailto:lead@buyer.com" in html
assert "John Doe" in html
@pytest.mark.asyncio
async def test_html_contains_urgency_callout(self, db):
async def test_html_contains_urgency_callout(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
# Urgency callout has yellow background
assert "#FEF3C7" in html
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
# Urgency callout has yellow background
assert "#FEF3C7" in html
@pytest.mark.asyncio
async def test_html_contains_direct_reply_cta(self, db):
async def test_html_contains_direct_reply_cta(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
# Direct reply link text should mention the contact email
assert "lead@buyer.com" in html
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send_email)["html"]
# Direct reply link text should mention the contact email
assert "lead@buyer.com" in html
@pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db):
async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio
async def test_updates_email_sent_at(self, db):
async def test_updates_email_sent_at(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
async with db.execute(
"SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
@@ -331,30 +290,24 @@ class TestLeadForward:
assert row["email_sent_at"] is not None
@pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self, db):
async def test_skips_when_no_supplier_email(self, db, mock_send_email):
"""No email on supplier record — handler exits without sending."""
lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send.assert_not_called()
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send_email.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db):
async def test_skips_when_lead_not_found(self, db, mock_send_email):
"""Non-existent lead_id — handler exits without sending."""
_, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send.assert_not_called()
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send_email.assert_not_called()
@pytest.mark.asyncio
async def test_design_elements_present(self, db):
async def test_design_elements_present(self, db, mock_send_email):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Lead Matched Notification ────────────────────────────────────
@@ -362,70 +315,55 @@ class TestLeadForward:
class TestLeadMatched:
@pytest.mark.asyncio
async def test_sends_to_lead_contact_email(self, db):
async def test_sends_to_lead_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send_email)["to"] == "lead@buyer.com"
@pytest.mark.asyncio
async def test_subject_contains_first_name(self, db):
async def test_subject_contains_first_name(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send)["subject"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send_email)["subject"]
@pytest.mark.asyncio
async def test_html_contains_what_happens_next(self, db):
async def test_html_contains_what_happens_next(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send)["html"]
# "What happens next" section and tip callout (blue bg)
assert "#F0F9FF" in html # tip callout background
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send_email)["html"]
# "What happens next" section and tip callout (blue bg)
assert "#F0F9FF" in html # tip callout background
@pytest.mark.asyncio
async def test_html_contains_project_context(self, db):
async def test_html_contains_project_context(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send)["html"]
assert "Indoor" in html
assert "Germany" in html
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send_email)["html"]
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db):
async def test_uses_leads_from_addr(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": 99999})
mock_send.assert_not_called()
async def test_skips_when_lead_not_found(self, db, mock_send_email):
await handle_send_lead_matched_notification({"lead_id": 99999})
mock_send_email.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_no_contact_email(self, db):
async def test_skips_when_no_contact_email(self, db, mock_send_email):
lead_id = await _seed_lead(db, contact_email="")
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send.assert_not_called()
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send_email.assert_not_called()
@pytest.mark.asyncio
async def test_design_elements_present(self, db):
async def test_design_elements_present(self, db, mock_send_email):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Supplier Enquiry ─────────────────────────────────────────────
@@ -441,50 +379,43 @@ class TestSupplierEnquiry:
}
@pytest.mark.asyncio
async def test_sends_to_supplier_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
async def test_sends_to_supplier_email(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["to"] == "supplier@corp.com"
@pytest.mark.asyncio
async def test_subject_contains_contact_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
async def test_subject_contains_contact_name(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert "Alice Smith" in _call_kwargs(mock_send_email)["subject"]
@pytest.mark.asyncio
async def test_html_contains_message(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "4 courts" in html
assert "alice@buyer.com" in html
async def test_html_contains_message(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
assert "4 courts" in html
assert "alice@buyer.com" in html
@pytest.mark.asyncio
async def test_html_contains_respond_fast_nudge(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
# The respond-fast nudge line should be present
assert "24" in html # "24 hours" reference
async def test_html_contains_respond_fast_nudge(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send_email)["html"]
# The respond-fast nudge line should be present
assert "24" in html # "24 hours" reference
@pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
mock_send.assert_not_called()
async def test_skips_when_no_supplier_email(self, mock_send_email):
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
mock_send_email.assert_not_called()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
async def test_uses_transactional_from_addr(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send_email)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send)["html"])
async def test_design_elements_present(self, mock_send_email):
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send_email)["html"])
# ── Waitlist (supplement existing test_waitlist.py) ──────────────
@@ -494,33 +425,29 @@ class TestWaitlistEmails:
"""Verify design & content for waitlist confirmation emails."""
@pytest.mark.asyncio
async def test_general_waitlist_has_preheader(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
html = _call_kwargs(mock_send)["html"]
assert "display:none" in html # preheader span
async def test_general_waitlist_has_preheader(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
html = _call_kwargs(mock_send_email)["html"]
assert "display:none" in html # preheader span
@pytest.mark.asyncio
async def test_supplier_waitlist_mentions_plan(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
kw = _call_kwargs(mock_send)
assert "growth" in kw["subject"].lower()
assert "supplier" in kw["html"].lower()
async def test_supplier_waitlist_mentions_plan(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
kw = _call_kwargs(mock_send_email)
assert "growth" in kw["subject"].lower()
assert "supplier" in kw["html"].lower()
@pytest.mark.asyncio
async def test_general_waitlist_design_elements(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
_assert_common_design(_call_kwargs(mock_send)["html"])
async def test_general_waitlist_design_elements(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
_assert_common_design(_call_kwargs(mock_send_email)["html"])
@pytest.mark.asyncio
async def test_supplier_waitlist_perks_listed(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
html = _call_kwargs(mock_send)["html"]
# Should have <li> perks
assert html.count("<li>") >= 3
async def test_supplier_waitlist_perks_listed(self, mock_send_email):
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
html = _call_kwargs(mock_send_email)["html"]
# Should have <li> perks
assert html.count("<li>") >= 3
# ── DB seed helpers ──────────────────────────────────────────────

View File

@@ -10,7 +10,6 @@ import sqlite3
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics.core import utcnow_iso
from padelnomics.migrations.migrate import migrate
from padelnomics import core
@@ -25,25 +24,6 @@ def mock_csrf_validation():
yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin-role user session (module-level, follows test_content.py)."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("flags_admin@test.com", "Flags Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
async def _set_flag(db, name: str, enabled: bool, description: str = ""):
"""Insert or replace a flag in the test DB."""
await db.execute(

View File

@@ -46,26 +46,6 @@ def _bypass_csrf():
yield
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin user pre-loaded in session."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@example.com", "Admin User", now),
) as cursor:
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (user_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
async def _insert_supplier(
db,
name: str = "Test Supplier",

View File

@@ -14,31 +14,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import padelnomics.admin.pipeline_routes as pipeline_mod
import pytest
from padelnomics.core import utcnow_iso
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pipeline-admin@test.com", "Pipeline Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
@pytest.fixture
def state_db_dir():
"""Temp directory with a seeded .state.sqlite for testing."""

View File

@@ -10,7 +10,6 @@ Covers:
import json
from unittest.mock import patch
import pytest
from padelnomics.content.health import (
check_broken_scenario_refs,
check_hreflang_orphans,
@@ -27,26 +26,6 @@ from padelnomics import core
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin test client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("pseo-admin@test.com", "pSEO Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
# ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -89,26 +89,6 @@ async def articles_data(db, seo_data):
await db.commit()
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin client."""
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
) as cursor:
admin_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
)
await db.commit()
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = admin_id
yield c
# ── Query function tests ─────────────────────────────────────
class TestSearchPerformance:

View File

@@ -0,0 +1,413 @@
"""
End-to-end tests for the Stripe payment provider integration.
Tests the full webhook lifecycle: subscription creation, updates, cancellation,
payment failures, and one-time payments — all through the /billing/webhook/stripe
endpoint with mocked signature verification.
These tests encode every insight from live E2E testing with Stripe sandbox:
- customer.subscription.created fires (not checkout.session.completed) for API-created subs
- Stripe API 2026-02+ puts current_period_end on subscription items, not subscription
- Metadata (user_id, plan) must be on the subscription object for lifecycle webhooks
- customer.subscription.deleted sets status to "canceled" (American spelling)
- invoice.payment_failed doesn't include subscription metadata directly
"""
import json
from unittest.mock import patch
import pytest
from padelnomics.billing.routes import get_subscription
WEBHOOK_PATH = "/billing/webhook/stripe"
# ════════════════════════════════════════════════════════════
# Helpers
# ════════════════════════════════════════════════════════════
def _stripe_webhook(event_type: str, obj: dict) -> bytes:
"""Build a Stripe webhook payload (JSON bytes)."""
return json.dumps({"type": event_type, "data": {"object": obj}}).encode()
@pytest.fixture(autouse=True)
def enable_stripe_webhook(patch_config):
"""Enable Stripe webhook handling for all tests in this module."""
from padelnomics import core
core.config.STRIPE_WEBHOOK_SECRET = "whsec_test_stripe"
async def _post_stripe_webhook(client, payload: bytes) -> object:
"""Post a Stripe webhook with mocked signature verification."""
with patch("padelnomics.billing.stripe.verify_webhook", return_value=True):
return await client.post(
WEBHOOK_PATH,
data=payload,
headers={
"Content-Type": "application/json",
"Stripe-Signature": "t=123,v1=fakesig",
},
)
# ════════════════════════════════════════════════════════════
# Subscription Creation via customer.subscription.created
# ════════════════════════════════════════════════════════════
class TestStripeSubscriptionCreated:
"""customer.subscription.created is the primary event for new subscriptions."""
async def test_creates_subscription_in_db(self, client, db, test_user):
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_stripe_001",
"customer": "cus_stripe_001",
"status": "active",
"metadata": {"user_id": str(test_user["id"]), "plan": "starter"},
"items": {"data": [
{"price": {"id": "price_abc"}, "current_period_end": 1740000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub is not None
assert sub["plan"] == "starter"
assert sub["status"] == "active"
assert sub["provider_subscription_id"] == "sub_stripe_001"
async def test_period_end_from_items(self, client, db, test_user):
"""Stripe API 2026-02+ puts current_period_end on items, not subscription."""
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_stripe_002",
"customer": "cus_stripe_002",
"status": "active",
# No current_period_end at subscription level
"metadata": {"user_id": str(test_user["id"]), "plan": "pro"},
"items": {"data": [
{"price": {"id": "price_xyz"}, "current_period_end": 1740000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["current_period_end"] is not None
assert "2025-02-19" in sub["current_period_end"]
async def test_creates_billing_customer(self, client, db, test_user):
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_stripe_003",
"customer": "cus_stripe_003",
"status": "active",
"metadata": {"user_id": str(test_user["id"]), "plan": "starter"},
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
row = await db.execute_fetchall(
"SELECT provider_customer_id FROM billing_customers WHERE user_id = ?",
(test_user["id"],),
)
assert len(row) == 1
assert row[0][0] == "cus_stripe_003"
async def test_trialing_maps_to_on_trial(self, client, db, test_user):
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_stripe_trial",
"customer": "cus_stripe_trial",
"status": "trialing",
"metadata": {"user_id": str(test_user["id"]), "plan": "starter"},
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
# subscription.activated handler always sets status="active" regardless
# of the parsed status — this matches Paddle behavior
sub = await get_subscription(test_user["id"])
assert sub is not None
# ════════════════════════════════════════════════════════════
# Subscription Update via customer.subscription.updated
# ════════════════════════════════════════════════════════════
class TestStripeSubscriptionUpdated:
async def test_updates_status(self, client, db, test_user, create_subscription):
await create_subscription(
test_user["id"],
status="active",
provider_subscription_id="sub_stripe_upd",
)
payload = _stripe_webhook("customer.subscription.updated", {
"id": "sub_stripe_upd",
"customer": "cus_stripe_upd",
"status": "past_due",
"metadata": {"user_id": str(test_user["id"]), "plan": "starter"},
"items": {"data": [
{"price": {"id": "price_abc"}, "current_period_end": 1750000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "past_due"
async def test_updates_period_end(self, client, db, test_user, create_subscription):
await create_subscription(
test_user["id"],
status="active",
provider_subscription_id="sub_stripe_period",
)
payload = _stripe_webhook("customer.subscription.updated", {
"id": "sub_stripe_period",
"customer": "cus_stripe_period",
"status": "active",
"metadata": {},
"items": {"data": [
{"price": {"id": "price_abc"}, "current_period_end": 1750000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["current_period_end"] is not None
# ════════════════════════════════════════════════════════════
# Subscription Cancellation via customer.subscription.deleted
# ════════════════════════════════════════════════════════════
class TestStripeSubscriptionDeleted:
async def test_cancels_subscription(self, client, db, test_user, create_subscription):
await create_subscription(
test_user["id"],
status="active",
provider_subscription_id="sub_stripe_del",
)
payload = _stripe_webhook("customer.subscription.deleted", {
"id": "sub_stripe_del",
"customer": "cus_stripe_del",
"status": "canceled", # Stripe uses American spelling
"metadata": {"user_id": str(test_user["id"])},
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "cancelled" # Our DB uses British spelling
# ════════════════════════════════════════════════════════════
# Payment Failure via invoice.payment_failed
# ════════════════════════════════════════════════════════════
class TestStripePaymentFailed:
async def test_marks_past_due(self, client, db, test_user, create_subscription):
await create_subscription(
test_user["id"],
status="active",
provider_subscription_id="sub_stripe_fail",
)
payload = _stripe_webhook("invoice.payment_failed", {
"subscription": "sub_stripe_fail",
"customer": "cus_stripe_fail",
"metadata": {},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "past_due"
# ════════════════════════════════════════════════════════════
# Checkout Session (one-time payment)
# ════════════════════════════════════════════════════════════
class TestStripeCheckoutOneTime:
async def test_payment_mode_returns_200(self, client, db, test_user):
"""One-time payment checkout doesn't create a subscription."""
payload = _stripe_webhook("checkout.session.completed", {
"mode": "payment",
"customer": "cus_stripe_otp",
"metadata": {"user_id": str(test_user["id"]), "plan": "business_plan"},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
# No subscription created for one-time payments
sub = await get_subscription(test_user["id"])
assert sub is None
# ════════════════════════════════════════════════════════════
# Full Lifecycle: create → update → cancel
# ════════════════════════════════════════════════════════════
class TestStripeFullLifecycle:
async def test_create_update_cancel(self, client, db, test_user):
"""Simulate the full Stripe subscription lifecycle via webhooks."""
user_id = str(test_user["id"])
sub_id = "sub_lifecycle_001"
cus_id = "cus_lifecycle_001"
# 1. customer.subscription.created → DB row created
payload = _stripe_webhook("customer.subscription.created", {
"id": sub_id,
"customer": cus_id,
"status": "active",
"metadata": {"user_id": user_id, "plan": "starter"},
"items": {"data": [
{"price": {"id": "price_starter"}, "current_period_end": 1740000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "active"
assert sub["plan"] == "starter"
# 2. customer.subscription.updated → status change
payload = _stripe_webhook("customer.subscription.updated", {
"id": sub_id,
"customer": cus_id,
"status": "past_due",
"metadata": {"user_id": user_id, "plan": "starter"},
"items": {"data": [
{"price": {"id": "price_starter"}, "current_period_end": 1742000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "past_due"
# 3. customer.subscription.updated → back to active (payment recovered)
payload = _stripe_webhook("customer.subscription.updated", {
"id": sub_id,
"customer": cus_id,
"status": "active",
"metadata": {"user_id": user_id, "plan": "starter"},
"items": {"data": [
{"price": {"id": "price_starter"}, "current_period_end": 1745000000},
]},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "active"
# 4. customer.subscription.deleted → cancelled
payload = _stripe_webhook("customer.subscription.deleted", {
"id": sub_id,
"customer": cus_id,
"status": "canceled",
"metadata": {"user_id": user_id},
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
sub = await get_subscription(test_user["id"])
assert sub["status"] == "cancelled"
# ════════════════════════════════════════════════════════════
# Edge Cases
# ════════════════════════════════════════════════════════════
class TestStripeEdgeCases:
async def test_unknown_event_returns_200(self, client, db):
"""Unknown Stripe events should be accepted (200) but ignored."""
payload = _stripe_webhook("some.unknown.event", {"customer": "cus_x"})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
async def test_missing_metadata_does_not_crash(self, client, db):
"""Subscription without metadata should not 500."""
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_no_meta",
"customer": "cus_no_meta",
"status": "active",
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
async def test_empty_items_does_not_crash(self, client, db, test_user):
"""Subscription with empty items list should not 500."""
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_empty_items",
"customer": "cus_empty_items",
"status": "active",
"metadata": {"user_id": str(test_user["id"]), "plan": "starter"},
"items": {"data": []},
})
response = await _post_stripe_webhook(client, payload)
assert response.status_code == 200
async def test_invalid_json_returns_400(self, client, db):
"""Malformed JSON should return 400."""
with patch("padelnomics.billing.stripe.verify_webhook", return_value=True):
response = await client.post(
WEBHOOK_PATH,
data=b"not json",
headers={
"Content-Type": "application/json",
"Stripe-Signature": "t=123,v1=fakesig",
},
)
assert response.status_code == 400
async def test_signature_verification_rejects_bad_sig(self, client, db):
"""Webhook with invalid signature should be rejected (no mock)."""
payload = _stripe_webhook("customer.subscription.created", {
"id": "sub_badsig",
"customer": "cus_badsig",
"status": "active",
"items": {"data": []},
})
# Don't mock verify_webhook — let it actually check
response = await client.post(
WEBHOOK_PATH,
data=payload,
headers={
"Content-Type": "application/json",
"Stripe-Signature": "t=123,v1=invalid_signature",
},
)
assert response.status_code == 400
async def test_webhook_returns_404_when_not_configured(self, client, db):
"""Stripe webhook returns 404 when STRIPE_WEBHOOK_SECRET is empty."""
from padelnomics import core
core.config.STRIPE_WEBHOOK_SECRET = ""
response = await client.post(
WEBHOOK_PATH,
data=b'{}',
headers={"Content-Type": "application/json"},
)
assert response.status_code == 404

View File

@@ -188,59 +188,55 @@ class TestWorkerTask:
"""Test send_waitlist_confirmation worker task."""
@pytest.mark.asyncio
async def test_sends_entrepreneur_confirmation(self):
async def test_sends_entrepreneur_confirmation(self, mock_send_email):
"""Task sends confirmation email for entrepreneur signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
await handle_send_waitlist_confirmation({
"email": "entrepreneur@example.com",
"intent": "signup",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_sends_supplier_confirmation(self):
async def test_sends_supplier_confirmation(self, mock_send_email):
"""Task sends confirmation email for supplier signup."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_growth",
})
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
assert call_args.kwargs["to"] == "supplier@example.com"
assert "growth" in call_args.kwargs["subject"].lower()
assert "supplier" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio
async def test_supplier_email_includes_plan_name(self):
async def test_supplier_email_includes_plan_name(self, mock_send_email):
"""Supplier confirmation should mention the specific plan."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
await handle_send_waitlist_confirmation({
"email": "supplier@example.com",
"intent": "supplier_pro",
})
call_args = mock_send.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
call_args = mock_send_email.call_args
html = call_args.kwargs["html"]
assert "pro" in html.lower()
@pytest.mark.asyncio
async def test_uses_transactional_email_address(self):
async def test_uses_transactional_email_address(self, mock_send_email):
"""Task should use transactional sender address."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
await handle_send_waitlist_confirmation({
"email": "test@example.com",
"intent": "signup",
})
call_args = mock_send.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
call_args = mock_send_email.call_args
assert call_args.kwargs["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
# ── TestAuthRoutes ────────────────────────────────────────────────