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:
parent
70cf6148ce
commit
2297f9b2ed
11 changed files with 757 additions and 117 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
"/log/{day}",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
dependencies=[Depends(require_token)],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/{day}", response_class=HTMLResponse)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 · <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 · ingest hourly @ :10 UTC{% else %}last 6h · <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"
|
||||||
|
|
|
||||||
|
|
@ -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 “as is” without warranties of any
|
The service is provided “as is” 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>
|
||||||
|
|
|
||||||
|
|
@ -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’s headlines and the cross-asset signals into
|
We tie the day’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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 — 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 — the news
|
Two tiers. The core editorial is free today — 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 & 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 — news, indicators, and a strategic log every 6 hours.</div>
|
||||||
<div class="tier-card__price">£0</div>
|
<div class="tier-card__price">£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 — last 6 hours, auto-tagged by theme, click-to-filter</li>
|
<li>News feed — <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, …) with a one-paragraph AI read on each tab</li>
|
<li>Cross-asset indicator panels (equities, rates, FX, commodities, credit, …) with a one-paragraph AI read on each tab</li>
|
||||||
<li>Hourly strategic log — a single editorial interpretation of the day, updated each hour</li>
|
<li>Strategic log — a single editorial interpretation of the day, <strong>refreshed every 6 hours</strong></li>
|
||||||
<li>Ask follow-up questions on any past log — the chat has the day’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> — the week behind + the week ahead, one-click unsubscribe</li>
|
<li><strong>Sunday weekly digest</strong> by email — week behind + week ahead, one-click unsubscribe</li>
|
||||||
<li class="tier-card__excluded">Portfolio import & 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> →
|
||||||
|
</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">£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 — full 24 hours</strong> (vs. 6 hours on Free)</li>
|
Or <strong>£70 / year</strong> — two months free. Prices
|
||||||
<li><strong>Daily email digest</strong> (Mon–Sat) — 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 — 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 — 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 — a whole session in view, nothing rolls off</li>
|
||||||
|
<li><strong>Strategic log refreshed every hour</strong> instead of every six — track intraday moves as they unfold</li>
|
||||||
|
<li><strong>Follow-up chat on any past log</strong> — ask the model a question against the day’s full context</li>
|
||||||
|
<li><strong>Daily email digest</strong> (Mon–Sat) — ~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> — diversification, sector and currency concentration, macro-regime fit on your holdings</li>
|
||||||
|
<li><strong>Optional encrypted cloud sync</strong> — 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 — 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 — 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 — headlines from the last…</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">✓</td>
|
||||||
|
<td class="compare-table__paid">✓</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Follow-up chat on past logs</th>
|
||||||
|
<td class="compare-table__none">—</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–Sat</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Portfolio import (broker CSV)</th>
|
||||||
|
<td class="compare-table__none">—</td>
|
||||||
|
<td class="compare-table__paid"><strong>Included</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">AI portfolio read</th>
|
||||||
|
<td class="compare-table__none">—</td>
|
||||||
|
<td class="compare-table__paid"><strong>Included</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Encrypted cloud sync</th>
|
||||||
|
<td class="compare-table__none">—</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">🎁</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">×</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 — 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 — 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’t refundable for cash — see <a href="/terms">Terms & Conditions § 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>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{% extends "public_base.html" %}
|
{% extends "public_base.html" %}
|
||||||
{% block title %}{{ BRAND_NAME }} · Terms of Service{% endblock %}
|
{% block title %}{{ BRAND_NAME }} · 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 “Service”) from {{ OPERATOR_JURISDICTION }}.
|
(the “Service”) 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’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 & ownership</h2>
|
<h2 class="public-section__head">8. Content & ownership</h2>
|
||||||
<p>
|
<p>
|
||||||
The Service’s code, design, indicator selection, and prompts
|
The Service’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 & termination</h2>
|
<h2 class="public-section__head">9. Suspension & 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 “as is” and “as
|
The Service is provided “as is” and “as
|
||||||
available”, without warranties of any kind, express or implied,
|
available”, 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’s country of residence,
|
to any mandatory law of the consumer’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>
|
||||||
|
|
|
||||||
145
tests/test_chat_and_log_gates.py
Normal file
145
tests/test_chat_and_log_gates.py
Normal 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue