diff --git a/app/routers/api.py b/app/routers/api.py index f721716..5e06090 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -322,31 +322,54 @@ def _resolve_tone_param(tone: str | None) -> str: return "INTERMEDIATE" +def _free_tier_hour_filter(): + """Free-tier cadence filter for the strategic log: restrict matches to + logs generated at one of the 6-hour boundary hours (00, 06, 12, 18 + UTC). The job itself runs at :20 every hour, so this effectively gives + free users a fresh log roughly every six hours.""" + from app.services.access import FREE_LOG_HOURS_UTC + # `func.extract` works on both MariaDB and SQLite. + return func.extract("hour", StrategicLog.generated_at).in_(FREE_LOG_HOURS_UTC) + + @router.get("/log/latest") async def log_latest( request: Request, session: AsyncSession = Depends(get_session), as_: str | None = Query(default=None, alias="as"), tone: str | None = Query(default=None), + principal: CurrentUser | None = Depends(maybe_current_user), ): + from app.services.access import is_paid_active + free_only = not is_paid_active(principal) wanted_tone = _resolve_tone_param(tone) - row = (await session.execute( + + stmt = ( select(StrategicLog) .where(StrategicLog.tone == wanted_tone) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + stmt = stmt.where(_free_tier_hour_filter()) + row = (await session.execute(stmt)).scalar_one_or_none() # Fallback during rollout: if the requested tone isn't produced yet, # serve whatever is latest rather than 404 the panel. if row is None: - row = (await session.execute( - select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1) - )).scalar_one_or_none() + fallback = ( + select(StrategicLog) + .order_by(desc(StrategicLog.generated_at)) + .limit(1) + ) + if free_only: + fallback = fallback.where(_free_tier_hour_filter()) + row = (await session.execute(fallback)).scalar_one_or_none() if as_ == "html": return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row), "tone": wanted_tone}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: @@ -361,34 +384,46 @@ async def log_by_date( session: AsyncSession = Depends(get_session), as_: str | None = Query(default=None, alias="as"), tone: str | None = Query(default=None), + principal: CurrentUser | None = Depends(maybe_current_user), ): """Canonical log for a given day = MAX(generated_at) within that day, - filtered by tone (NOVICE | INTERMEDIATE; default from settings).""" + filtered by tone (NOVICE | INTERMEDIATE; default from settings). + Free-tier users only see logs generated at the 6-hour boundary slots.""" try: target = datetime.strptime(day, "%Y-%m-%d").date() except ValueError: raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD") + from app.services.access import is_paid_active + free_only = not is_paid_active(principal) wanted_tone = _resolve_tone_param(tone) - row = (await session.execute( + + stmt = ( select(StrategicLog) .where(func.date(StrategicLog.generated_at) == target) .where(StrategicLog.tone == wanted_tone) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + stmt = stmt.where(_free_tier_hour_filter()) + row = (await session.execute(stmt)).scalar_one_or_none() if row is None: - # Fallback: any tone for that day. - row = (await session.execute( + # Fallback: any tone for that day (still tier-filtered). + fallback = ( select(StrategicLog) .where(func.date(StrategicLog.generated_at) == target) .order_by(desc(StrategicLog.generated_at)) .limit(1) - )).scalar_one_or_none() + ) + if free_only: + fallback = fallback.where(_free_tier_hour_filter()) + row = (await session.execute(fallback)).scalar_one_or_none() if as_ == "html": return templates.TemplateResponse( request, "partials/log.html", - {"log": _log_partial_payload(row), "tone": wanted_tone}, + {"log": _log_partial_payload(row), "tone": wanted_tone, + "paid": not free_only}, ) if row is None: raise HTTPException(status_code=404, detail="No log on this date") @@ -744,11 +779,22 @@ async def _month_spend(session: AsyncSession) -> float: async def chat( body: ChatRequest, session: AsyncSession = Depends(get_session), + principal: CurrentUser | None = Depends(maybe_current_user), ): """Answer one user turn given the conversation so far. Grounded on the latest strategic log + market data + thesis-filtered headlines. Ephemeral — the conversation lives entirely in the client; the endpoint just records each call's cost in `ai_calls`.""" + # Paid-only feature. Free users get the static log but not the + # interactive chat (see /pricing). + from app.services.access import is_paid_active + if not is_paid_active(principal): + raise HTTPException( + status_code=402, + detail={"code": "paid_required", + "message": "Follow-up chat is a paid-tier feature."}, + ) + s = get_settings() if not s.OPENROUTER_API_KEY: raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set") diff --git a/app/routers/pages.py b/app/routers/pages.py index 7790d18..993ec0f 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -12,7 +12,7 @@ from app.auth import CurrentUser, maybe_current_user, require_auth, require_toke from app.config import get_settings, load_groups from app.db import get_session from app.models import EmailSend, Referral, StrategicLog, User -from app.services.access import paid_status +from app.services.access import is_paid_active, paid_status from app.services.referral_service import assign_code_if_missing from app.templates_env import templates @@ -37,7 +37,8 @@ async def root_page( return templates.TemplateResponse( request, "dashboard.html", - {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu}, + {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, + "cu": cu, "paid": is_paid_active(cu)}, ) @@ -74,41 +75,40 @@ async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: return datetime.now(timezone.utc).date() -def _log_page_context(target: date) -> dict: +def _log_page_context(target: date, paid: bool) -> dict: s = get_settings() return { "selected_iso": target.isoformat(), "selected_month": target.strftime("%Y-%m"), "current_tone": s.CASSANDRA_TONE.upper(), "current_analysis": s.CASSANDRA_ANALYSIS.upper(), + "paid": paid, } -@router.get( - "/log", - response_class=HTMLResponse, - dependencies=[Depends(require_token)], -) +@router.get("/log", response_class=HTMLResponse) async def log_page( request: Request, session: AsyncSession = Depends(get_session), + cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, None) - return templates.TemplateResponse(request, "log.html", _log_page_context(target)) + return templates.TemplateResponse( + request, "log.html", _log_page_context(target, is_paid_active(cu)), + ) -@router.get( - "/log/{day}", - response_class=HTMLResponse, - dependencies=[Depends(require_token)], -) +@router.get("/log/{day}", response_class=HTMLResponse) async def log_page_day( request: Request, day: str, session: AsyncSession = Depends(get_session), + cu: CurrentUser = Depends(require_auth), ): target = await _resolve_log_date(session, day) - return templates.TemplateResponse(request, "log.html", _log_page_context(target)) + return templates.TemplateResponse( + request, "log.html", _log_page_context(target, is_paid_active(cu)), + ) @router.get("/settings", response_class=HTMLResponse) diff --git a/app/services/access.py b/app/services/access.py index 731114a..da388f6 100644 --- a/app/services/access.py +++ b/app/services/access.py @@ -26,6 +26,13 @@ from app.models import User # endpoint's `since_hours` param requests (up to its own max). 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: return datetime.now(timezone.utc) diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 0f2f7a8..2e1765a 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -681,6 +681,25 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } .log-page__cal { padding: 10px; } .log-page__content { min-height: 60vh; } .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 --------------------------------------------------- */ @@ -1527,15 +1546,24 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } margin: 0 0 24px; } .hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; } -.hero__ctas .btn-primary, -.hero__ctas .btn-secondary { +/* Shared button shape — was previously scoped to .hero__ctas, which made + the pricing-card CTAs render as bare anchors. */ +.btn-primary, +.btn-secondary { display: inline-block; padding: 10px 22px; border-radius: 3px; font-size: 13.5px; font-weight: 500; + line-height: 1.4; 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 :link/:visited UA defaults. Without `a.btn-primary` the cascade can 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 { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 18px; - margin: 8px 0 24px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin: 8px 0 40px; } .tier-card { + position: relative; border: 1px solid var(--border); - border-radius: 4px; - padding: 22px 22px 26px; + border-radius: 6px; + padding: 28px 26px 28px; background: var(--surface); display: flex; flex-direction: column; } .tier-card--featured { 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-size: 11px; - color: var(--muted); - letter-spacing: 0.08em; + font-size: 10px; + letter-spacing: 0.10em; 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 { - font-size: 22px; + font-size: 40px; font-weight: 700; 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 { font-size: 12px; 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 { list-style: none; padding: 0; - margin: 0 0 22px; + margin: 0 0 24px; flex: 1; } .tier-card li { font-size: 13.5px; color: var(--text); - padding: 6px 0; + line-height: 1.55; + padding: 8px 0 8px 22px; + position: relative; border-bottom: 1px solid var(--border); } .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.tier-card__excluded { color: var(--muted); } -.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); } -.tier-card__cta { margin-top: auto; } +.tier-card li::before { + content: "✓"; + position: absolute; + 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-chip { @@ -1908,3 +2042,127 @@ a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); } color: var(--accent); 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 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 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; +} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 8e778d1..784535d 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -61,7 +61,9 @@
Strategic Log - generated hourly @ :20 UTC + + {% if paid %}refreshed hourly @ :20 UTC{% else %}refreshed every 6 hours · hourly on Paid{% endif %} +
Flash News - last 24h · ingest hourly @ :10 UTC + + {% if paid %}last 24h · ingest hourly @ :10 UTC{% else %}last 6h · full 24h on Paid{% endif %} +

This page is part of, and qualifies, the - Terms of Service. + Terms and Conditions.

@@ -95,7 +95,7 @@ The service is provided “as is” without warranties of any kind. To the maximum extent permitted by applicable law, the operator excludes liability for any loss arising from use of, or reliance on, - the content. See the Terms of Service for the + the content. See the Terms and Conditions for the full limitation of liability.

diff --git a/app/templates/landing.html b/app/templates/landing.html index 95deadc..2732a68 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -11,8 +11,8 @@ tune out the high-frequency noise that comes from treating markets like a casino. We aggregate cross-asset news and macro signals, then write a plain-English read of what the underlying fundamentals - justify versus what the crowd is doing. Refreshed hourly. A media - service, not a financial one. + justify versus what the crowd is doing. Refreshed through the + trading day. A media service, not a financial one.

{% if cu and (cu.user or cu.is_admin) %} @@ -75,7 +75,7 @@
-
The hourly read
+
The strategic read

Rational vs irrational, every paragraph

We tie the day’s headlines and the cross-asset signals into @@ -88,8 +88,8 @@

@@ -115,10 +115,11 @@

- Paid users can also drop a Trading 212 pie CSV for an AI - sense-check on concentration, regime fit, and currency exposure. - Holdings stay in your browser by default; opt in to encrypted cloud - sync to restore on another device. + Paid users can also drop a portfolio CSV from their broker + (Trading 212 today, more brokers planned) for an AI sense-check on + concentration, regime fit, and currency exposure. Holdings stay in + your browser by default; opt in to encrypted cloud sync to restore + on another device.

diff --git a/app/templates/log.html b/app/templates/log.html index 3e56727..8abee4c 100644 --- a/app/templates/log.html +++ b/app/templates/log.html @@ -30,6 +30,7 @@
loading log…
+ {% if paid %} + {% else %} + + {% endif %}
- +{% if paid %}{% endif %} {% endblock %} diff --git a/app/templates/pricing.html b/app/templates/pricing.html index 595d9fe..a26e106 100644 --- a/app/templates/pricing.html +++ b/app/templates/pricing.html @@ -6,78 +6,190 @@

Pricing

- Two tiers. Most of the editorial stays free — the news - aggregator, the hourly AI read, the indicator panels, the - follow-up chat. Paid extends the news window from 6 hours to a - full 24, unlocks portfolio import & analysis, and adds a - daily email digest on top of the Sunday recap everyone gets. + Two tiers. The core editorial is free today — a rolling + 6-hour news feed, the cross-asset indicator panels, and a strategic + log refreshed every six hours. Paid stretches the news feed to a + full 24 hours, runs the strategic log hourly, unlocks the follow-up + 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.

-
Free
+

Free

+
The core editorial — news, indicators, and a strategic log every 6 hours.
£0
-
Forever. No card needed.
+
No card needed.
+
+
What you get
    -
  • News aggregator — last 6 hours, auto-tagged by theme, click-to-filter
  • +
  • News feed — headlines from the last 6 hours, auto-tagged by theme, click-to-filter
  • Cross-asset indicator panels (equities, rates, FX, commodities, credit, …) with a one-paragraph AI read on each tab
  • -
  • Hourly strategic log — a single editorial interpretation of the day, updated each hour
  • -
  • Ask follow-up questions on any past log — the chat has the day’s context loaded
  • +
  • Strategic log — a single editorial interpretation of the day, refreshed every 6 hours
  • Two reading levels: Novice (defines jargon) or Intermediate (terse, for fluent readers)
  • -
  • Sunday weekly digest by email — the week behind + the week ahead, one-click unsubscribe
  • -
  • Portfolio import & analysis
  • -
  • Encrypted cloud sync
  • -
  • Daily email digest
  • +
  • Sunday weekly digest by email — week behind + week ahead, one-click unsubscribe
+
+ Need the full-day news feed, hourly strategic log, follow-up chat, daily digests, or portfolio analysis? See Paid → +
{% if cu and (cu.user or cu.is_admin) %} - Open dashboard + Open dashboard {% else %} - Sign up free + Sign up free {% endif %}
-

- Invite a friend. Every account gets a personal - invite link from the Settings page. When friends sign up through - it, we credit you toward the paid tier — the credit ledger - is live; the rate kicks in with the payments rollout. -

+

Free vs Paid at a glance

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureFreePaid
News feed — headlines from the last…6 hours24 hours
Strategic log refreshEvery 6 hoursEvery hour
Cross-asset indicator panels
Follow-up chat on past logsIncluded
Email digestSunday onlySunday + daily Mon–Sat
Portfolio import (broker CSV)Included
AI portfolio readIncluded
Encrypted cloud syncIncluded
+
+ +
+
Invite a friend
+
Both of you get 50% off for 3 months
+
+ Share your personal invite link from Settings. The discount applies when they start a paid plan. +
+
+ +
+ + + +

Invite a friend

+

+ Every account gets an 8-character referral code and matching invite + link, both shown on your Settings page. When + someone signs up through your link and starts a paid plan, + both of you get 50% off for the next three months. +

+

How it works

+
    +
  1. Sign up. Your code and link go live in Settings.
  2. +
  3. Share. Send the link, or read the code — the alphabet drops 0/O and 1/I/L so it dictates cleanly.
  4. +
  5. They sign up. The referral is recorded against your account when they verify their email.
  6. +
  7. They subscribe. The discount applies to their next bill and credits against yours.
  8. +
+

The fine print

+
    +
  • One referral per new account — whichever link they used first.
  • +
  • No self-referral.
  • +
  • The credit ledger is live today; the cash value kicks in when paid checkout opens. Referrals logged in the meantime are honoured.
  • +
  • Credits aren’t refundable for cash — see Terms & Conditions § 6.
  • +
  • Pending signups, conversions, and active credits are visible on the Settings page.
  • +
+
+ + +

How the data is handled

diff --git a/app/templates/terms.html b/app/templates/terms.html index cee279e..cb0dd1d 100644 --- a/app/templates/terms.html +++ b/app/templates/terms.html @@ -1,12 +1,12 @@ {% extends "public_base.html" %} -{% block title %}{{ BRAND_NAME }} · Terms of Service{% endblock %} +{% block title %}{{ BRAND_NAME }} · Terms and Conditions{% endblock %} {% block main %}

-

Terms of Service

+

Terms and Conditions

- 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 }}.

@@ -96,7 +96,55 @@
-

6. Service availability

+

6. Refunds

+

+ 14-day cooling-off (UK / EU consumers). 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. +

+

+ Cancellation after the cooling-off window. 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. +

+

+ Termination by us without fault on your part. 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. +

+

+ Service faults. 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. +

+

+ How to request a refund. Email + {{ OPERATOR_EMAIL }} 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. +

+

+ Referral credits and any other non-cash credits applied to your + account are not refundable for cash. +

+
+ +
+

7. Service availability

The Service is provided on a best-effort basis. There is no service level agreement: outages, data delays, and feature changes may occur @@ -105,7 +153,7 @@

-

7. Content & ownership

+

8. Content & ownership

The Service’s code, design, indicator selection, and prompts are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any @@ -126,7 +174,7 @@

-

8. Suspension & termination

+

9. Suspension & termination

We may suspend or terminate access without notice for violation of these Terms or for activity that risks the integrity, security, or @@ -138,12 +186,13 @@ respond, unless immediate suspension is necessary to protect the Service, its users, or any third party. If we terminate a paid 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).

-

9. No warranty

+

10. No warranty

The Service is provided “as is” and “as available”, without warranties of any kind, express or implied, @@ -153,7 +202,7 @@

-

10. Limitation of liability

+

11. Limitation of liability

To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not liable for any indirect, incidental, special, consequential, or @@ -182,7 +231,7 @@

-

11. Changes

+

12. Changes

These Terms may change. Material changes will be flagged in-app or by email. Continued use after a change means you accept the updated @@ -193,7 +242,7 @@

-

12. Governing law and jurisdiction

+

13. Governing law and jurisdiction

These Terms are governed by the laws of England and Wales. Subject to any mandatory law of the consumer’s country of residence, @@ -205,7 +254,7 @@

-

13. Contact

+

14. Contact

{{ OPERATOR_EMAIL }}

diff --git a/tests/test_chat_and_log_gates.py b/tests/test_chat_and_log_gates.py new file mode 100644 index 0000000..bff5997 --- /dev/null +++ b/tests/test_chat_and_log_gates.py @@ -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"