pricing: land £7/£70 paid tier and make behaviour match

Marketing + behaviour pass to get the site ready for Paddle approval.

Pricing page
- £7/month, £70/year headline (was "Coming soon").
- Bigger tier names (was 11px uppercase mono — looked like chips).
- Real CTAs (button base styles were only scoped to .hero__ctas).
- "Best value" badge + drop-shadow on the Paid card; full-width
  block CTAs that align across both cards.
- "Free vs Paid at a glance" comparison table beneath the cards.
- Compact "Invite a friend — both get 50% off for 3 months"
  callout with the detail explanation behind a <dialog> popup.

Tier copy + behaviour now consistent
- Free strategic-log refresh is every 6 hours, not hourly. New
  read-side filter on /api/log/{latest,by-date} restricts free
  users to logs at boundary hours (00/06/12/18 UTC); paid users
  still see the most recent.
- Follow-up chat is paid-only. /api/chat returns 402 for free;
  the chat sidebar on /log is replaced with a locked aside and
  chat.js no longer loads at all for free users.
- Dashboard meta lines + landing copy softened so they no longer
  promise hourly to everyone.

Future-proofing copy on public pages
- Dropped "free forever" wording (we may close the free tier).
- "Trading 212 CSV" became "broker CSV (Trading 212 today; more
  planned)" on pricing + landing; the actual import UIs stay
  T212-specific.

Terms
- Renamed Terms of Service -> Terms and Conditions (Paddle
  expectation), bumped last-updated to 2026-05-26.
- New §6 Refunds covering the 14-day cooling off, post-window
  cancellation, termination-by-us refunds, statutory rights, and
  how to request a refund.
- Renumbered §7-§14 and fixed the disclaimer link labels.

Tests
- 6 new tests in tests/test_chat_and_log_gates.py cover the
  chat 402 + the boundary-hour filter on both log endpoints.
- Full suite: 205 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-26 11:34:37 +02:00
parent 70cf6148ce
commit 2297f9b2ed
11 changed files with 757 additions and 117 deletions

View file

@ -322,31 +322,54 @@ def _resolve_tone_param(tone: str | None) -> str:
return "INTERMEDIATE"
def _free_tier_hour_filter():
"""Free-tier cadence filter for the strategic log: restrict matches to
logs generated at one of the 6-hour boundary hours (00, 06, 12, 18
UTC). The job itself runs at :20 every hour, so this effectively gives
free users a fresh log roughly every six hours."""
from app.services.access import FREE_LOG_HOURS_UTC
# `func.extract` works on both MariaDB and SQLite.
return func.extract("hour", StrategicLog.generated_at).in_(FREE_LOG_HOURS_UTC)
@router.get("/log/latest")
async def log_latest(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
principal: CurrentUser | None = Depends(maybe_current_user),
):
from app.services.access import is_paid_active
free_only = not is_paid_active(principal)
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
stmt = (
select(StrategicLog)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
)
if free_only:
stmt = stmt.where(_free_tier_hour_filter())
row = (await session.execute(stmt)).scalar_one_or_none()
# Fallback during rollout: if the requested tone isn't produced yet,
# serve whatever is latest rather than 404 the panel.
if row is None:
row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
fallback = (
select(StrategicLog)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)
if free_only:
fallback = fallback.where(_free_tier_hour_filter())
row = (await session.execute(fallback)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone},
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
)
if row is None:
@ -361,34 +384,46 @@ async def log_by_date(
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Canonical log for a given day = MAX(generated_at) within that day,
filtered by tone (NOVICE | INTERMEDIATE; default from settings)."""
filtered by tone (NOVICE | INTERMEDIATE; default from settings).
Free-tier users only see logs generated at the 6-hour boundary slots."""
try:
target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
from app.services.access import is_paid_active
free_only = not is_paid_active(principal)
wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
stmt = (
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
)
if free_only:
stmt = stmt.where(_free_tier_hour_filter())
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
# Fallback: any tone for that day.
row = (await session.execute(
# Fallback: any tone for that day (still tier-filtered).
fallback = (
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
)
if free_only:
fallback = fallback.where(_free_tier_hour_filter())
row = (await session.execute(fallback)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html",
{"log": _log_partial_payload(row), "tone": wanted_tone},
{"log": _log_partial_payload(row), "tone": wanted_tone,
"paid": not free_only},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
@ -744,11 +779,22 @@ async def _month_spend(session: AsyncSession) -> float:
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
# Paid-only feature. Free users get the static log but not the
# interactive chat (see /pricing).
from app.services.access import is_paid_active
if not is_paid_active(principal):
raise HTTPException(
status_code=402,
detail={"code": "paid_required",
"message": "Follow-up chat is a paid-tier feature."},
)
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")

View file

@ -12,7 +12,7 @@ from app.auth import CurrentUser, maybe_current_user, require_auth, require_toke
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import EmailSend, Referral, StrategicLog, User
from app.services.access import paid_status
from app.services.access import is_paid_active, paid_status
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates
@ -37,7 +37,8 @@ async def root_page(
return templates.TemplateResponse(
request,
"dashboard.html",
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu},
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE,
"cu": cu, "paid": is_paid_active(cu)},
)
@ -74,41 +75,40 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
return datetime.now(timezone.utc).date()
def _log_page_context(target: date) -> dict:
def _log_page_context(target: date, paid: bool) -> dict:
s = get_settings()
return {
"selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"),
"current_tone": s.CASSANDRA_TONE.upper(),
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
"paid": paid,
}
@router.get(
"/log",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
@router.get("/log", response_class=HTMLResponse)
async def log_page(
request: Request,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, None)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)
@router.get(
"/log/{day}",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
@router.get("/log/{day}", response_class=HTMLResponse)
async def log_page_day(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
):
target = await _resolve_log_date(session, day)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
return templates.TemplateResponse(
request, "log.html", _log_page_context(target, is_paid_active(cu)),
)
@router.get("/settings", response_class=HTMLResponse)