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" 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") @router.get("/log/latest")
async def log_latest( async def log_latest(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"), as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None), 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) wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
stmt = (
select(StrategicLog) select(StrategicLog)
.where(StrategicLog.tone == wanted_tone) .where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at)) .order_by(desc(StrategicLog.generated_at))
.limit(1) .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, # Fallback during rollout: if the requested tone isn't produced yet,
# serve whatever is latest rather than 404 the panel. # serve whatever is latest rather than 404 the panel.
if row is None: if row is None:
row = (await session.execute( fallback = (
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1) select(StrategicLog)
)).scalar_one_or_none() .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": if as_ == "html":
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/log.html", 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: if row is None:
@ -361,34 +384,46 @@ async def log_by_date(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"), as_: str | None = Query(default=None, alias="as"),
tone: str | None = Query(default=None), 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, """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: try:
target = datetime.strptime(day, "%Y-%m-%d").date() target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD") 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) wanted_tone = _resolve_tone_param(tone)
row = (await session.execute(
stmt = (
select(StrategicLog) select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target) .where(func.date(StrategicLog.generated_at) == target)
.where(StrategicLog.tone == wanted_tone) .where(StrategicLog.tone == wanted_tone)
.order_by(desc(StrategicLog.generated_at)) .order_by(desc(StrategicLog.generated_at))
.limit(1) .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: if row is None:
# Fallback: any tone for that day. # Fallback: any tone for that day (still tier-filtered).
row = (await session.execute( fallback = (
select(StrategicLog) select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target) .where(func.date(StrategicLog.generated_at) == target)
.order_by(desc(StrategicLog.generated_at)) .order_by(desc(StrategicLog.generated_at))
.limit(1) .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": if as_ == "html":
return templates.TemplateResponse( return templates.TemplateResponse(
request, "partials/log.html", 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: if row is None:
raise HTTPException(status_code=404, detail="No log on this date") 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( async def chat(
body: ChatRequest, body: ChatRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
principal: CurrentUser | None = Depends(maybe_current_user),
): ):
"""Answer one user turn given the conversation so far. Grounded on the """Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines. latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`.""" 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() s = get_settings()
if not s.OPENROUTER_API_KEY: if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set") 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.config import get_settings, load_groups
from app.db import get_session from app.db import get_session
from app.models import EmailSend, Referral, StrategicLog, User 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.services.referral_service import assign_code_if_missing
from app.templates_env import templates from app.templates_env import templates
@ -37,7 +37,8 @@ async def root_page(
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"dashboard.html", "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() 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() s = get_settings()
return { return {
"selected_iso": target.isoformat(), "selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"), "selected_month": target.strftime("%Y-%m"),
"current_tone": s.CASSANDRA_TONE.upper(), "current_tone": s.CASSANDRA_TONE.upper(),
"current_analysis": s.CASSANDRA_ANALYSIS.upper(), "current_analysis": s.CASSANDRA_ANALYSIS.upper(),
"paid": paid,
} }
@router.get( @router.get("/log", response_class=HTMLResponse)
"/log",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page( async def log_page(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
): ):
target = await _resolve_log_date(session, None) 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( @router.get("/log/{day}", response_class=HTMLResponse)
"/log/{day}",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page_day( async def log_page_day(
request: Request, request: Request,
day: str, day: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
): ):
target = await _resolve_log_date(session, day) 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) @router.get("/settings", response_class=HTMLResponse)

View file

@ -26,6 +26,13 @@ from app.models import User
# endpoint's `since_hours` param requests (up to its own max). # endpoint's `since_hours` param requests (up to its own max).
FREE_NEWS_WINDOW_HOURS = 6.0 FREE_NEWS_WINDOW_HOURS = 6.0
# The strategic-log job runs at :20 every hour (during trading windows).
# Free-tier users only see logs generated at these UTC hours — so the
# log refreshes for them roughly every 6 hours (00:20, 06:20, 12:20,
# 18:20). Paid users see the absolute latest log. Filtering happens
# read-side; we don't generate per-tier rows.
FREE_LOG_HOURS_UTC: tuple[int, ...] = (0, 6, 12, 18)
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)

View file

@ -681,6 +681,25 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
.log-page__cal { padding: 10px; } .log-page__cal { padding: 10px; }
.log-page__content { min-height: 60vh; } .log-page__content { min-height: 60vh; }
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; } .log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
.log-page__chat--locked { opacity: 0.92; }
.chat-locked {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 16px;
padding: 24px 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
border: 1px dashed var(--border);
border-radius: 4px;
margin: 8px 4px;
}
.chat-locked p { margin: 0; max-width: 280px; }
.chat-locked strong { color: var(--text); display: block; margin-bottom: 6px; }
/* --- Calendar widget --------------------------------------------------- */ /* --- Calendar widget --------------------------------------------------- */
@ -1527,15 +1546,24 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin: 0 0 24px; margin: 0 0 24px;
} }
.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; } .hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
.hero__ctas .btn-primary, /* Shared button shape was previously scoped to .hero__ctas, which made
.hero__ctas .btn-secondary { the pricing-card CTAs render as bare anchors. */
.btn-primary,
.btn-secondary {
display: inline-block; display: inline-block;
padding: 10px 22px; padding: 10px 22px;
border-radius: 3px; border-radius: 3px;
font-size: 13.5px; font-size: 13.5px;
font-weight: 500; font-weight: 500;
line-height: 1.4;
text-decoration: none; text-decoration: none;
text-align: center;
cursor: pointer;
} }
/* Block variant: full-width within parent, slightly taller used inside
tier cards so each CTA spans the card and reads as the obvious action. */
.btn-block { display: block; width: 100%; padding: 12px 22px; font-size: 14px; }
/* Qualify with `a` so we beat `a { color: var(--accent) }` and any /* Qualify with `a` so we beat `a { color: var(--accent) }` and any
:link/:visited UA defaults. Without `a.btn-primary` the cascade can :link/:visited UA defaults. Without `a.btn-primary` the cascade can
resolve in favour of the visited-link color on some browsers and the resolve in favour of the visited-link color on some browsers and the
@ -1665,58 +1693,164 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
.tier-grid { .tier-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px; gap: 20px;
margin: 8px 0 24px; margin: 8px 0 40px;
} }
.tier-card { .tier-card {
position: relative;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 6px;
padding: 22px 22px 26px; padding: 28px 26px 28px;
background: var(--surface); background: var(--surface);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.tier-card--featured { .tier-card--featured {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset; box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(15, 23, 42, 0.10);
} }
.tier-card__name { [data-theme="dark"] .tier-card--featured {
box-shadow: 0 0 0 1px var(--accent) inset,
0 12px 32px rgba(0, 0, 0, 0.45);
}
.tier-card__badge {
position: absolute;
top: -11px;
left: 24px;
background: var(--accent);
color: var(--bg);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 10px;
color: var(--muted); letter-spacing: 0.10em;
letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 8px; font-weight: 600;
padding: 4px 10px;
border-radius: 3px;
}
/* Tier name the actual heading, not the small uppercase chip it used
to be. Pairs with .tier-card__tagline for a one-line value framing. */
.tier-card__name {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 4px;
line-height: 1.1;
}
.tier-card__tagline {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
margin-bottom: 22px;
} }
.tier-card__price { .tier-card__price {
font-size: 22px; font-size: 40px;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
margin-bottom: 4px; line-height: 1;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.tier-card__price-unit {
font-size: 15px;
color: var(--muted);
font-weight: 400;
letter-spacing: 0;
} }
.tier-card__price-hint { .tier-card__price-hint {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
margin-bottom: 18px; line-height: 1.55;
margin-bottom: 20px;
}
.tier-card__divider {
height: 1px;
background: var(--border);
margin: 0 0 18px;
}
.tier-card__list-head {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
letter-spacing: 0.10em;
text-transform: uppercase;
margin-bottom: 12px;
} }
.tier-card ul { .tier-card ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0 0 22px; margin: 0 0 24px;
flex: 1; flex: 1;
} }
.tier-card li { .tier-card li {
font-size: 13.5px; font-size: 13.5px;
color: var(--text); color: var(--text);
padding: 6px 0; line-height: 1.55;
padding: 8px 0 8px 22px;
position: relative;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.tier-card li:last-child { border-bottom: 0; } .tier-card li:last-child { border-bottom: 0; }
.tier-card li::before { content: "✓ "; color: var(--positive); font-weight: 700; margin-right: 4px; } .tier-card li::before {
.tier-card li.tier-card__excluded { color: var(--muted); } content: "✓";
.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); } position: absolute;
.tier-card__cta { margin-top: auto; } left: 0;
top: 8px;
color: var(--positive);
font-weight: 700;
}
.tier-card__cta { margin-top: 18px; }
.tier-card__more {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
/* Side-by-side feature comparison table. Lives below the cards and
makes the deltas readable at a glance the cards sell, the table
confirms. */
.compare-table {
width: 100%;
border-collapse: collapse;
margin: 0 0 16px;
font-size: 13.5px;
}
.compare-table th,
.compare-table td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
line-height: 1.5;
}
.compare-table thead th {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.compare-table th[scope="row"] {
font-weight: 500;
color: var(--text);
width: 38%;
}
.compare-table td.compare-table__free { color: var(--muted); }
.compare-table td.compare-table__paid { color: var(--text); font-weight: 500; }
.compare-table td.compare-table__paid strong { color: var(--accent); font-weight: 600; }
.compare-table td.compare-table__none { color: var(--dim); }
@media (max-width: 520px) {
.compare-table th[scope="row"] { width: 50%; }
.compare-table th, .compare-table td { padding: 10px 8px; font-size: 13px; }
}
/* BETA indicator pill in the app header — see app/templates/base.html. */ /* BETA indicator pill in the app header — see app/templates/base.html. */
.beta-chip { .beta-chip {
@ -1908,3 +2042,127 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
color: var(--accent); color: var(--accent);
outline: none; outline: none;
} }
/* --- Invite-a-friend callout (pricing) ----------------------------- */
/* A single-row visual banner that names the offer at a glance. The
detail text lives in a <dialog> behind the "How it works" button to
keep the pricing page scannable. */
.invite-callout {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
margin: 0 0 40px;
background: linear-gradient(135deg,
color-mix(in srgb, var(--accent) 12%, var(--surface)),
var(--surface));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
border-left: 4px solid var(--accent);
border-radius: 6px;
}
.invite-callout__icon {
font-size: 32px;
line-height: 1;
flex-shrink: 0;
filter: saturate(1.1);
}
.invite-callout__body { flex: 1; min-width: 0; }
.invite-callout__eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 4px;
}
.invite-callout__headline {
font-size: 19px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
margin-bottom: 4px;
}
.invite-callout__headline strong { color: var(--accent); font-weight: 700; }
.invite-callout__sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.invite-callout .btn-secondary { flex-shrink: 0; }
@media (max-width: 560px) {
.invite-callout { flex-direction: column; align-items: flex-start; gap: 14px; }
.invite-callout .btn-secondary { width: 100%; }
}
/* Generic text-only modal reuse for any "click for the details"
pattern. Same <dialog> mechanics as .shot-modal. */
.text-modal {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
max-width: min(92vw, 560px);
max-height: 88vh;
padding: 28px 28px 24px;
border-radius: 6px;
overflow-y: auto;
position: relative;
line-height: 1.6;
}
.text-modal::backdrop { background: rgba(0, 0, 0, 0.65); }
.text-modal__title {
font-size: 20px;
font-weight: 700;
margin: 0 0 14px;
padding-right: 36px;
color: var(--text);
}
.text-modal__head {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
margin: 20px 0 8px;
}
.text-modal p {
font-size: 13.5px;
color: var(--text);
margin: 0 0 12px;
}
.text-modal__list {
margin: 0 0 8px;
padding-left: 22px;
}
.text-modal__list li {
font-size: 13.5px;
color: var(--text);
margin-bottom: 6px;
line-height: 1.55;
}
.text-modal code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--surface-2);
padding: 1px 5px;
border-radius: 2px;
}
.text-modal__close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: 0;
color: var(--muted);
font-size: 26px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: var(--font-mono);
}
.text-modal__close:hover,
.text-modal__close:focus-visible {
color: var(--accent);
outline: none;
}

View file

@ -61,7 +61,9 @@
<section id="log-panel" class="panel"> <section id="log-panel" class="panel">
<div class="panel-header"> <div class="panel-header">
<span class="title">Strategic Log</span> <span class="title">Strategic Log</span>
<span class="meta">generated hourly @ :20 UTC</span> <span class="meta">
{% if paid %}refreshed hourly @ :20 UTC{% else %}refreshed every 6 hours &middot; <a href="/pricing">hourly on Paid</a>{% endif %}
</span>
</div> </div>
<div class="panel-body" <div class="panel-body"
hx-get="/api/log/latest?as=html" hx-get="/api/log/latest?as=html"
@ -74,7 +76,9 @@
<section id="news-panel" class="panel"> <section id="news-panel" class="panel">
<div class="panel-header"> <div class="panel-header">
<span class="title">Flash News</span> <span class="title">Flash News</span>
<span class="meta">last 24h · ingest hourly @ :10 UTC</span> <span class="meta">
{% if paid %}last 24h &middot; ingest hourly @ :10 UTC{% else %}last 6h &middot; <a href="/pricing">full 24h on Paid</a>{% endif %}
</span>
</div> </div>
<div class="panel-body panel-body--scroll" <div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=40" hx-get="/api/news?as=html&limit=40"

View file

@ -12,7 +12,7 @@
</p> </p>
<p style="margin-top:6px; font-size:12px; color: var(--muted);"> <p style="margin-top:6px; font-size:12px; color: var(--muted);">
This page is part of, and qualifies, the This page is part of, and qualifies, the
<a href="/terms">Terms of Service</a>. <a href="/terms">Terms and Conditions</a>.
</p> </p>
</section> </section>
@ -95,7 +95,7 @@
The service is provided &ldquo;as is&rdquo; without warranties of any The service is provided &ldquo;as is&rdquo; without warranties of any
kind. To the maximum extent permitted by applicable law, the operator kind. To the maximum extent permitted by applicable law, the operator
excludes liability for any loss arising from use of, or reliance on, excludes liability for any loss arising from use of, or reliance on,
the content. See the <a href="/terms">Terms of Service</a> for the the content. See the <a href="/terms">Terms and Conditions</a> for the
full limitation of liability. full limitation of liability.
</p> </p>
</section> </section>

View file

@ -11,8 +11,8 @@
tune out the high-frequency noise that comes from treating markets tune out the high-frequency noise that comes from treating markets
like a casino. We aggregate cross-asset news and macro signals, like a casino. We aggregate cross-asset news and macro signals,
then write a plain-English read of what the underlying fundamentals then write a plain-English read of what the underlying fundamentals
justify versus what the crowd is doing. Refreshed hourly. A media justify versus what the crowd is doing. Refreshed through the
service, not a financial one. trading day. A media service, not a financial one.
</p> </p>
<div class="hero__ctas"> <div class="hero__ctas">
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
@ -75,7 +75,7 @@
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-card__tag">The hourly read</div> <div class="feature-card__tag">The strategic read</div>
<h3 class="feature-card__title">Rational vs irrational, every paragraph</h3> <h3 class="feature-card__title">Rational vs irrational, every paragraph</h3>
<p class="feature-card__body"> <p class="feature-card__body">
We tie the day&rsquo;s headlines and the cross-asset signals into We tie the day&rsquo;s headlines and the cross-asset signals into
@ -88,8 +88,8 @@
</p> </p>
<button class="shot feature-card__shot" <button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}" data-full="{{ url_for('static', path='/images/strategic-log.png') }}"
data-alt="Strategic log — the hourly AI read" data-alt="Strategic log — the editorial AI read"
data-caption="The strategic log. The model writes a fresh interpretation each hour, taking yesterday's draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature."> data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
<img src="{{ url_for('static', path='/images/strategic-log.png') }}" <img src="{{ url_for('static', path='/images/strategic-log.png') }}"
alt="Strategic log thumbnail" loading="lazy"> alt="Strategic log thumbnail" loading="lazy">
</button> </button>
@ -115,10 +115,11 @@
<section class="public-section"> <section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);"> <p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a Trading 212 pie CSV for an AI Paid users can also drop a portfolio CSV from their broker
sense-check on concentration, regime fit, and currency exposure. (Trading 212 today, more brokers planned) for an AI sense-check on
Holdings stay in your browser by default; opt in to encrypted cloud concentration, regime fit, and currency exposure. Holdings stay in
sync to restore on another device. your browser by default; opt in to encrypted cloud sync to restore
on another device.
</p> </p>
</section> </section>

View file

@ -30,6 +30,7 @@
<div class="empty">loading log…</div> <div class="empty">loading log…</div>
</article> </article>
{% if paid %}
<aside id="chat-sidebar" class="log-page__chat"> <aside id="chat-sidebar" class="log-page__chat">
<div class="chat-header"> <div class="chat-header">
<span class="chat-title">Ask Cassandra</span> <span class="chat-title">Ask Cassandra</span>
@ -49,7 +50,24 @@
<button id="chat-send" type="submit">Send</button> <button id="chat-send" type="submit">Send</button>
</form> </form>
</aside> </aside>
{% else %}
<aside class="log-page__chat log-page__chat--locked">
<div class="chat-header">
<span class="chat-title">Ask Cassandra</span>
<span class="chat-hint">paid-tier feature</span>
</div>
<div class="chat-locked">
<p>
<strong>Follow-up chat is a paid feature.</strong>
Ask the model a question about any past log &mdash; it sees the
full day's context: the strategic log, live market readings, and
the last 24 hours of thesis-filtered headlines.
</p>
<a class="btn-primary" href="/pricing">See pricing</a>
</div>
</aside>
{% endif %}
</div> </div>
</section> </section>
<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script> {% if paid %}<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>{% endif %}
{% endblock %} {% endblock %}

View file

@ -6,78 +6,190 @@
<section class="public-section"> <section class="public-section">
<h1 class="public-section__head">Pricing</h1> <h1 class="public-section__head">Pricing</h1>
<p> <p>
Two tiers. Most of the editorial stays free &mdash; the news Two tiers. The core editorial is free today &mdash; a rolling
aggregator, the hourly AI read, the indicator panels, the 6-hour news feed, the cross-asset indicator panels, and a strategic
follow-up chat. Paid extends the news window from 6 hours to a log refreshed every six hours. Paid stretches the news feed to a
full 24, unlocks portfolio import &amp; analysis, and adds a full 24 hours, runs the strategic log hourly, unlocks the follow-up
daily email digest on top of the Sunday recap everyone gets. chat against past logs, adds portfolio import with AI analysis, and
turns on the daily email digest on top of the Sunday recap everyone
gets.
</p> </p>
</section> </section>
<section class="tier-grid"> <section class="tier-grid">
<div class="tier-card"> <div class="tier-card">
<div class="tier-card__name">Free</div> <h2 class="tier-card__name">Free</h2>
<div class="tier-card__tagline">The core editorial &mdash; news, indicators, and a strategic log every 6 hours.</div>
<div class="tier-card__price">&pound;0</div> <div class="tier-card__price">&pound;0</div>
<div class="tier-card__price-hint">Forever. No card needed.</div> <div class="tier-card__price-hint">No card needed.</div>
<div class="tier-card__divider"></div>
<div class="tier-card__list-head">What you get</div>
<ul> <ul>
<li>News aggregator &mdash; last 6 hours, auto-tagged by theme, click-to-filter</li> <li>News feed &mdash; <strong>headlines from the last 6 hours</strong>, auto-tagged by theme, click-to-filter</li>
<li>Cross-asset indicator panels (equities, rates, FX, commodities, credit, &hellip;) with a one-paragraph AI read on each tab</li> <li>Cross-asset indicator panels (equities, rates, FX, commodities, credit, &hellip;) with a one-paragraph AI read on each tab</li>
<li>Hourly strategic log &mdash; a single editorial interpretation of the day, updated each hour</li> <li>Strategic log &mdash; a single editorial interpretation of the day, <strong>refreshed every 6 hours</strong></li>
<li>Ask follow-up questions on any past log &mdash; the chat has the day&rsquo;s context loaded</li>
<li>Two reading levels: <em>Novice</em> (defines jargon) or <em>Intermediate</em> (terse, for fluent readers)</li> <li>Two reading levels: <em>Novice</em> (defines jargon) or <em>Intermediate</em> (terse, for fluent readers)</li>
<li><strong>Sunday weekly digest by email</strong> &mdash; the week behind + the week ahead, one-click unsubscribe</li> <li><strong>Sunday weekly digest</strong> by email &mdash; week behind + week ahead, one-click unsubscribe</li>
<li class="tier-card__excluded">Portfolio import &amp; analysis</li>
<li class="tier-card__excluded">Encrypted cloud sync</li>
<li class="tier-card__excluded">Daily email digest</li>
</ul> </ul>
<div class="tier-card__more">
Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See <strong>Paid</strong> &rarr;
</div>
<div class="tier-card__cta"> <div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %} {% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary" href="/">Open dashboard</a> <a class="btn-secondary btn-block" href="/">Open dashboard</a>
{% else %} {% else %}
<a class="btn-primary" href="/login">Sign up free</a> <a class="btn-primary btn-block" href="/login">Sign up free</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="tier-card tier-card--featured"> <div class="tier-card tier-card--featured">
<div class="tier-card__name">Paid</div> <div class="tier-card__badge">Best value</div>
<div class="tier-card__price">Coming soon</div> <h2 class="tier-card__name">Paid</h2>
<div class="tier-card__price-hint">Checkout opens with our payments rollout.</div> <div class="tier-card__tagline">Full-day news feed, hourly strategic log, follow-up chat, and AI portfolio analysis.</div>
<ul> <div class="tier-card__price">&pound;7<span class="tier-card__price-unit"> / month</span></div>
<li>Everything in Free</li> <div class="tier-card__price-hint">
<li><strong>News aggregator &mdash; full 24 hours</strong> (vs. 6 hours on Free)</li> Or <strong>&pound;70 / year</strong> &mdash; two months free. Prices
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; a ~600-word read of the session ahead, in addition to the Sunday recap</li> in GBP, VAT where applicable. Checkout opens with the payments
<li>Portfolio import from a Trading 212 CSV</li> rollout.
<li>AI commentary on diversification, sector and currency concentration, and macro-regime fit for the holdings you upload</li>
<li>Optional encrypted cloud sync &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
</ul>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary" href="/settings">Manage account</a>
{% else %}
<a class="btn-primary" href="/login">Sign up &mdash; paid unlocks soon</a>
{% endif %}
</div> </div>
<p style="margin-top:14px; font-size:11.5px; color: var(--muted); font-style: italic; line-height:1.55;"> <div class="tier-card__divider"></div>
<div class="tier-card__list-head">Everything in Free, plus</div>
<ul>
<li><strong>News feed: headlines from the last 24 hours</strong> instead of 6 &mdash; a whole session in view, nothing rolls off</li>
<li><strong>Strategic log refreshed every hour</strong> instead of every six &mdash; track intraday moves as they unfold</li>
<li><strong>Follow-up chat on any past log</strong> &mdash; ask the model a question against the day&rsquo;s full context</li>
<li><strong>Daily email digest</strong> (Mon&ndash;Sat) &mdash; ~600-word read of the session ahead, on top of the Sunday recap</li>
<li><strong>Portfolio import</strong> from a broker CSV (Trading 212 supported today; more brokers planned)</li>
<li><strong>AI portfolio read</strong> &mdash; diversification, sector and currency concentration, macro-regime fit on your holdings</li>
<li><strong>Optional encrypted cloud sync</strong> &mdash; PIN-derived encryption in your browser, second-layer wrap on the server, no plaintext holdings server-side</li>
</ul>
<p class="tier-card__more" style="font-style: italic;">
The portfolio feature does not produce buy, sell or hold The portfolio feature does not produce buy, sell or hold
recommendations. It does not consider your wider finances, debts, recommendations and does not consider your wider finances, debts,
tax position or objectives. It is not regulated investment advice tax position or objectives. It is not regulated investment advice
or a personal recommendation under FSMA / FCA COBS. or a personal recommendation under FSMA / FCA COBS.
</p> </p>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary btn-block" href="/settings">Manage account</a>
{% else %}
<a class="btn-primary btn-block" href="/login">Sign up &mdash; paid unlocks soon</a>
{% endif %}
</div>
</div> </div>
</section> </section>
<section class="public-section"> <section class="public-section">
<p style="font-size: 13px; color: var(--muted);"> <h2 class="public-section__head">Free vs Paid at a glance</h2>
<strong>Invite a friend.</strong> Every account gets a personal <table class="compare-table">
invite link from the Settings page. When friends sign up through <thead>
it, we credit you toward the paid tier &mdash; the credit ledger <tr>
is live; the rate kicks in with the payments rollout. <th scope="col">Feature</th>
</p> <th scope="col">Free</th>
<th scope="col">Paid</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">News feed &mdash; headlines from the last&hellip;</th>
<td class="compare-table__free">6 hours</td>
<td class="compare-table__paid"><strong>24 hours</strong></td>
</tr>
<tr>
<th scope="row">Strategic log refresh</th>
<td class="compare-table__free">Every 6 hours</td>
<td class="compare-table__paid"><strong>Every hour</strong></td>
</tr>
<tr>
<th scope="row">Cross-asset indicator panels</th>
<td class="compare-table__free">&check;</td>
<td class="compare-table__paid">&check;</td>
</tr>
<tr>
<th scope="row">Follow-up chat on past logs</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Email digest</th>
<td class="compare-table__free">Sunday only</td>
<td class="compare-table__paid"><strong>Sunday + daily Mon&ndash;Sat</strong></td>
</tr>
<tr>
<th scope="row">Portfolio import (broker CSV)</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">AI portfolio read</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
<tr>
<th scope="row">Encrypted cloud sync</th>
<td class="compare-table__none">&mdash;</td>
<td class="compare-table__paid"><strong>Included</strong></td>
</tr>
</tbody>
</table>
</section> </section>
<section class="invite-callout">
<div class="invite-callout__icon" aria-hidden="true">&#x1F381;</div>
<div class="invite-callout__body">
<div class="invite-callout__eyebrow">Invite a friend</div>
<div class="invite-callout__headline">Both of you get <strong>50% off for 3 months</strong></div>
<div class="invite-callout__sub">
Share your personal invite link from <a href="/settings">Settings</a>. The discount applies when they start a paid plan.
</div>
</div>
<button type="button" class="btn-secondary" id="invite-more">How it works</button>
</section>
<dialog id="invite-modal" class="text-modal" aria-label="How the referral works">
<button type="button" class="text-modal__close" aria-label="Close">&times;</button>
<h2 class="text-modal__title">Invite a friend</h2>
<p>
Every account gets an 8-character referral code and matching invite
link, both shown on your <a href="/settings">Settings</a> page. When
someone signs up through your link and starts a paid plan,
<strong>both of you get 50% off for the next three months</strong>.
</p>
<h3 class="text-modal__head">How it works</h3>
<ol class="text-modal__list">
<li><strong>Sign up.</strong> Your code and link go live in Settings.</li>
<li><strong>Share.</strong> Send the link, or read the code &mdash; the alphabet drops <code>0/O</code> and <code>1/I/L</code> so it dictates cleanly.</li>
<li><strong>They sign up.</strong> The referral is recorded against your account when they verify their email.</li>
<li><strong>They subscribe.</strong> The discount applies to their next bill and credits against yours.</li>
</ol>
<h3 class="text-modal__head">The fine print</h3>
<ul class="text-modal__list">
<li>One referral per new account &mdash; whichever link they used first.</li>
<li>No self-referral.</li>
<li>The credit ledger is live today; the cash value kicks in when paid checkout opens. Referrals logged in the meantime are honoured.</li>
<li>Credits aren&rsquo;t refundable for cash &mdash; see <a href="/terms">Terms &amp; Conditions &sect; 6</a>.</li>
<li>Pending signups, conversions, and active credits are visible on the Settings page.</li>
</ul>
</dialog>
<script>
(function () {
var dlg = document.getElementById('invite-modal');
var open = document.getElementById('invite-more');
if (!dlg || !dlg.showModal || !open) return;
open.addEventListener('click', function () { dlg.showModal(); });
dlg.addEventListener('click', function (e) {
if (e.target === dlg) dlg.close();
});
dlg.querySelector('.text-modal__close').addEventListener('click', function () {
dlg.close();
});
})();
</script>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">How the data is handled</h2> <h2 class="public-section__head">How the data is handled</h2>
<p> <p>

View file

@ -1,12 +1,12 @@
{% extends "public_base.html" %} {% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Terms of Service{% endblock %} {% block title %}{{ BRAND_NAME }} &middot; Terms and Conditions{% endblock %}
{% block main %} {% block main %}
<section class="public-section"> <section class="public-section">
<h1 class="public-section__head">Terms of Service</h1> <h1 class="public-section__head">Terms and Conditions</h1>
<p style="color: var(--muted); font-size: 13px;"> <p style="color: var(--muted); font-size: 13px;">
Last updated: 2026-05-24. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }} Last updated: 2026-05-26. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }}
(the &ldquo;Service&rdquo;) from {{ OPERATOR_JURISDICTION }}. (the &ldquo;Service&rdquo;) from {{ OPERATOR_JURISDICTION }}.
</p> </p>
</section> </section>
@ -96,7 +96,55 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">6. Service availability</h2> <h2 class="public-section__head">6. Refunds</h2>
<p>
<strong>14-day cooling-off (UK / EU consumers).</strong> If you buy a
paid subscription as a consumer, you have 14 days from the day of
purchase to cancel and receive a full refund of that purchase, under
the Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013. As noted in clause 5, if you start using
a paid feature inside the cancellation window you lose the right to
cancel in respect of digital content already delivered.
</p>
<p>
<strong>Cancellation after the cooling-off window.</strong> You can
cancel a paid subscription at any time. Cancellation takes effect at
the end of the current billing period; we do not pro-rate refunds
for the unused portion of a period you have already started, unless
a separate paragraph below applies.
</p>
<p>
<strong>Termination by us without fault on your part.</strong> If we
terminate or materially reduce a paid feature for reasons that are
not a breach by you (including a service shutdown), we will refund
the unused portion of any prepaid fees on a pro-rata basis. The same
applies under clause 8 if we terminate for a breach you did not
cause, and under clause 11 if you close your account because you do
not accept a material change to these Terms.
</p>
<p>
<strong>Service faults.</strong> Nothing in this clause limits your
statutory rights as a UK consumer under Part 1 of the Consumer
Rights Act 2015. If a paid feature is not supplied with reasonable
care and skill, you may be entitled to a repeat performance or a
price reduction (which can be a full refund) under that Act.
</p>
<p>
<strong>How to request a refund.</strong> Email
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a> from
the address tied to your account, with the order reference if you
have one. We aim to acknowledge within 5 working days. Refunds are
returned to the original payment method and typically arrive within
14 days of approval, subject to your bank&rsquo;s processing time.
</p>
<p>
Referral credits and any other non-cash credits applied to your
account are not refundable for cash.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">7. Service availability</h2>
<p> <p>
The Service is provided on a best-effort basis. There is no service The Service is provided on a best-effort basis. There is no service
level agreement: outages, data delays, and feature changes may occur level agreement: outages, data delays, and feature changes may occur
@ -105,7 +153,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">7. Content &amp; ownership</h2> <h2 class="public-section__head">8. Content &amp; ownership</h2>
<p> <p>
The Service&rsquo;s code, design, indicator selection, and prompts The Service&rsquo;s code, design, indicator selection, and prompts
are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any
@ -126,7 +174,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">8. Suspension &amp; termination</h2> <h2 class="public-section__head">9. Suspension &amp; termination</h2>
<p> <p>
We may suspend or terminate access without notice for violation of We may suspend or terminate access without notice for violation of
these Terms or for activity that risks the integrity, security, or these Terms or for activity that risks the integrity, security, or
@ -138,12 +186,13 @@
respond, unless immediate suspension is necessary to protect the respond, unless immediate suspension is necessary to protect the
Service, its users, or any third party. If we terminate a paid Service, its users, or any third party. If we terminate a paid
subscription for a breach you did not cause, we will refund the subscription for a breach you did not cause, we will refund the
unused portion of any prepaid fees on a pro-rata basis. unused portion of any prepaid fees on a pro-rata basis (see
clause 6).
</p> </p>
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">9. No warranty</h2> <h2 class="public-section__head">10. No warranty</h2>
<p> <p>
The Service is provided &ldquo;as is&rdquo; and &ldquo;as The Service is provided &ldquo;as is&rdquo; and &ldquo;as
available&rdquo;, without warranties of any kind, express or implied, available&rdquo;, without warranties of any kind, express or implied,
@ -153,7 +202,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">10. Limitation of liability</h2> <h2 class="public-section__head">11. Limitation of liability</h2>
<p> <p>
To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not
liable for any indirect, incidental, special, consequential, or liable for any indirect, incidental, special, consequential, or
@ -182,7 +231,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">11. Changes</h2> <h2 class="public-section__head">12. Changes</h2>
<p> <p>
These Terms may change. Material changes will be flagged in-app or These Terms may change. Material changes will be flagged in-app or
by email. Continued use after a change means you accept the updated by email. Continued use after a change means you accept the updated
@ -193,7 +242,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">12. Governing law and jurisdiction</h2> <h2 class="public-section__head">13. Governing law and jurisdiction</h2>
<p> <p>
These Terms are governed by the laws of England and Wales. Subject These Terms are governed by the laws of England and Wales. Subject
to any mandatory law of the consumer&rsquo;s country of residence, to any mandatory law of the consumer&rsquo;s country of residence,
@ -205,7 +254,7 @@
</section> </section>
<section class="public-section"> <section class="public-section">
<h2 class="public-section__head">13. Contact</h2> <h2 class="public-section__head">14. Contact</h2>
<p> <p>
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a> <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>
</p> </p>

View file

@ -0,0 +1,145 @@
"""Free-vs-paid gating on /api/chat and the strategic-log read endpoints.
Mirrors the integration-style setup from test_news_window.py: real router
over an in-memory aiosqlite DB, with two seeded users (free + paid)."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
_TONE = "INTERMEDIATE" # matches CASSANDRA_TONE default
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import StrategicLog, User
from app.routers import api as api_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/gates.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
# Two logs on the same UTC day: the newer one is at hour 1 (non-boundary)
# and the older one is at hour 0 (boundary). Free users should see the
# boundary one; paid users should see the newer one.
base = datetime(2026, 5, 25, tzinfo=timezone.utc)
boundary_at = base.replace(hour=0, minute=20)
newer_at = base.replace(hour=1, minute=20)
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="free@x", tier="free"))
s.add(User(id=2, email="paid@x", tier="paid"))
s.add(StrategicLog(
generated_at=boundary_at, model="m", tone=_TONE,
analysis="DRY", content="boundary-log",
))
s.add(StrategicLog(
generated_at=newer_at, model="m", tone=_TONE,
analysis="DRY", content="non-boundary-log",
))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
client = TestClient(app)
return client, sign_session(1), sign_session(2)
# --- /api/chat gating ------------------------------------------------------
def test_chat_blocks_free_user_with_402(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.post(
"/api/chat",
json={"messages": [{"role": "user", "content": "hi"}]},
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 402, r.text
body = r.json()
assert body["detail"]["code"] == "paid_required"
def test_chat_lets_paid_user_past_the_gate(tmp_path, monkeypatch):
"""Paid users should clear the tier gate. The next check is the
OPENROUTER_API_KEY presence without a key it 503s, which is fine:
the point is that they got past the paid gate (not 402)."""
client, _, paid_sess = _build_app(tmp_path)
# Make sure no real key leaks in from the host environment.
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
from app.config import get_settings
get_settings.cache_clear() # type: ignore[attr-defined]
r = client.post(
"/api/chat",
json={"messages": [{"role": "user", "content": "hi"}]},
cookies={"cassandra_session": paid_sess},
)
# Paid clears the tier gate. With no API key configured the endpoint
# then 503s — that's the next check in the handler.
assert r.status_code in (503, 200), r.text
# --- /api/log/latest free-tier 6-hour throttle -----------------------------
def test_log_latest_free_user_sees_boundary_hour_only(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.get(
f"/api/log/latest?tone={_TONE}",
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "boundary-log", (
"free user must see the 00:20 boundary log, not the newer 01:20 one"
)
def test_log_latest_paid_user_sees_most_recent(tmp_path):
client, _, paid_sess = _build_app(tmp_path)
r = client.get(
f"/api/log/latest?tone={_TONE}",
cookies={"cassandra_session": paid_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "non-boundary-log", (
"paid user must see the most recent log regardless of generation hour"
)
# --- /api/log/by-date free-tier filter -------------------------------------
def test_log_by_date_free_user_filters_to_boundary(tmp_path):
client, free_sess, _ = _build_app(tmp_path)
r = client.get(
f"/api/log/by-date/2026-05-25?tone={_TONE}",
cookies={"cassandra_session": free_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "boundary-log"
def test_log_by_date_paid_user_sees_most_recent(tmp_path):
client, _, paid_sess = _build_app(tmp_path)
r = client.get(
f"/api/log/by-date/2026-05-25?tone={_TONE}",
cookies={"cassandra_session": paid_sess},
)
assert r.status_code == 200, r.text
assert r.json()["content"] == "non-boundary-log"