ui: collapsible settings sections + welcome-email + larger auth inputs
Settings page tidy-up driven by user feedback that it had grown too busy:
- Each section (Import, Invite, Email digests, Cloud sync) is now a
native <details>/<summary> accordion. Import stays open by default
because /settings#import is the deep-link target from the dashboard
CTA; the others collapse so the page lands quiet.
- Manage subscription is a right-aligned gear-icon button instead of
a rectangular text button — the descriptive copy moves into the
tooltip. Frees up the Tier row of visual weight.
Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.
The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.
Tests rewritten to cover the new welcome-email path:
- first login sends exactly one welcome email
- returning user gets none
- SMTP failure does not break the redirect
- regression guard: returning user who opted out stays opted out
Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a07fd144ea
commit
00211fec02
8 changed files with 553 additions and 124 deletions
|
|
@ -37,7 +37,7 @@ from app.db import get_session, utcnow
|
|||
from app.logging import get_logger
|
||||
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
||||
from app.services import otp_service, referral_service
|
||||
from app.services.email_service import EmailSendError, send_otp
|
||||
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
|
||||
from app.templates_env import templates
|
||||
|
||||
|
||||
|
|
@ -216,7 +216,6 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No
|
|||
async def verify_submit(
|
||||
request: Request,
|
||||
code: str = Form(...),
|
||||
subscribe_to_digests: str | None = Form(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
||||
|
|
@ -242,15 +241,24 @@ async def verify_submit(
|
|||
return RedirectResponse(url="/login", status_code=303)
|
||||
is_first_login = user.last_login_at is None
|
||||
user.last_login_at = utcnow()
|
||||
# Apply the verify-page subscribe checkbox ONLY at first sign-up. After
|
||||
# that, Settings (and the one-click unsubscribe link) own the preference
|
||||
# — re-applying on every login would silently re-subscribe users who
|
||||
# explicitly opted out.
|
||||
if is_first_login:
|
||||
user.email_digest_opt_in = subscribe_to_digests is not None
|
||||
# Default opt-in is set on User row creation; we don't touch it here.
|
||||
# The one-time welcome email below explains the digest and the Settings
|
||||
# opt-out path — re-applying a checkbox state on every login would
|
||||
# silently re-subscribe users who explicitly opted out later.
|
||||
await session.commit()
|
||||
log.info("user.login", user_id=user.id, email=email)
|
||||
|
||||
# First-login welcome email — best effort. SMTP failure must not block
|
||||
# the login itself; we log and continue. Idempotent because we commit
|
||||
# last_login_at above before this point, so a retried verify won't
|
||||
# re-trigger send.
|
||||
if is_first_login:
|
||||
try:
|
||||
await send_welcome_email(email)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("welcome_email.send_failed",
|
||||
user_id=user.id, error=str(e)[:200])
|
||||
|
||||
resp = RedirectResponse(url="/", status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
_clear_pending_cookie(resp)
|
||||
|
|
|
|||
|
|
@ -200,6 +200,130 @@ async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
|
|||
await send_email(to, subject, text, html_body=html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome email — sent once on first successful login.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_WELCOME_HTML_TEMPLATE = """\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Welcome to {brand}</title>
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
body {{ background:{D_bg} !important; }}
|
||||
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
|
||||
.h1 {{ color:{D_text} !important; }}
|
||||
.muted {{ color:{D_muted} !important; }}
|
||||
.lead {{ color:{D_text} !important; }}
|
||||
.divider {{ border-color:{D_border} !important; }}
|
||||
a {{ color:{D_accent} !important; }}
|
||||
}}
|
||||
@media (max-width: 540px) {{
|
||||
.card {{ padding:24px 18px !important; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text}; -webkit-font-smoothing:antialiased;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
|
||||
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
|
||||
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
|
||||
▰ {brand_upper}
|
||||
</div>
|
||||
<div style="height:22px; line-height:22px; font-size:0;"> </div>
|
||||
<div class="h1" style="font-size:17px; font-weight:normal; color:{L_text}; letter-spacing:0.02em;">
|
||||
Welcome to {brand}.
|
||||
</div>
|
||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
You’re signed in. The dashboard is at
|
||||
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> —
|
||||
a rolling news feed, cross-asset indicator panels, and a written
|
||||
strategic read of the session, all updated through the day.
|
||||
</div>
|
||||
<div style="height:20px; line-height:20px; font-size:0;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:18px; line-height:18px; font-size:0;"> </div>
|
||||
<div class="h1" style="font-size:14px; font-weight:normal; color:{L_text};">
|
||||
About the email digest
|
||||
</div>
|
||||
<div style="height:10px; line-height:10px; font-size:0;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
We send one Sunday digest to every account — the week behind +
|
||||
the week ahead. Paid subscribers also get a short daily digest
|
||||
(Mon–Sat), each a ~600-word read of the session.
|
||||
You’re opted in by default; you can switch the digest off
|
||||
at any time on the
|
||||
<a href="{settings_url}" style="color:{L_accent}; text-decoration:none;">Settings page</a>,
|
||||
or use the one-click unsubscribe link in every digest email.
|
||||
</div>
|
||||
<div style="height:20px; line-height:20px; font-size:0;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
|
||||
Sent automatically by {brand} · do not reply
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
_WELCOME_TEXT_TEMPLATE = """\
|
||||
{brand_upper} — welcome
|
||||
|
||||
You're signed in. The dashboard is at {app_url}: a rolling news feed,
|
||||
cross-asset indicator panels, and a written strategic read of the
|
||||
session, all updated through the day.
|
||||
|
||||
About the email digest
|
||||
----------------------
|
||||
We send one Sunday digest to every account (the week behind + the
|
||||
week ahead). Paid subscribers also get a short daily digest (Mon-Sat),
|
||||
each a ~600-word read of the session.
|
||||
|
||||
You're opted in by default; switch it off any time at {settings_url},
|
||||
or use the one-click unsubscribe link in every digest email.
|
||||
|
||||
—
|
||||
Sent automatically by {brand} · do not reply
|
||||
"""
|
||||
|
||||
|
||||
def render_welcome_email() -> tuple[str, str, str]:
|
||||
"""Returns (subject, text_body, html_body) for the post-signup welcome.
|
||||
|
||||
Single-shot email, sent the first time a user successfully verifies
|
||||
an OTP. Explains the digest (which is opt-in by default) and how to
|
||||
turn it off — replaces the old verify-page checkbox which appeared
|
||||
on every login and was misleading."""
|
||||
subject = f"Welcome to {branding.BRAND_NAME}"
|
||||
fmt = dict(
|
||||
brand=branding.BRAND_NAME,
|
||||
brand_upper=branding.BRAND_NAME.upper(),
|
||||
app_url=branding.APP_URL,
|
||||
app_url_short=branding.APP_URL.replace("https://", "").replace("http://", ""),
|
||||
settings_url=f"{branding.APP_URL}/settings",
|
||||
FONT_MONO=branding.FONT_MONO,
|
||||
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
|
||||
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
|
||||
)
|
||||
text = _WELCOME_TEXT_TEMPLATE.format(**fmt)
|
||||
html = _WELCOME_HTML_TEMPLATE.format(**fmt)
|
||||
return subject, text, html
|
||||
|
||||
|
||||
async def send_welcome_email(to: str) -> None:
|
||||
subject, text, html = render_welcome_email()
|
||||
await send_email(to, subject, text, html_body=html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Digest email rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -566,6 +566,53 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pf-actions .pf-secondary { color: var(--muted); }
|
||||
.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
|
||||
|
||||
/* Settings-page action button — same visual language as .pf-actions
|
||||
button so buttons across /settings (Manage subscription, future
|
||||
actions) read as one family. Standalone class (not nested under a
|
||||
parent) so it can be dropped onto any button anywhere on the page. */
|
||||
.settings-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.settings-btn:hover { border-color: var(--accent); }
|
||||
.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Icon-button variant for inline row actions (e.g. Manage subscription
|
||||
gear in the Tier row). Square hit area, accent on hover, tooltip via
|
||||
title attribute. */
|
||||
.settings-icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
|
||||
}
|
||||
.settings-icon-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.settings-icon-btn svg { display: block; }
|
||||
.pf-analysis {
|
||||
margin-top: 14px;
|
||||
background: var(--surface-2);
|
||||
|
|
@ -859,16 +906,46 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
letter-spacing: 0.06em;
|
||||
gap: 4px;
|
||||
}
|
||||
.auth-card input[type="email"], .auth-card input[type="password"] {
|
||||
.auth-card input[type="email"],
|
||||
.auth-card input[type="password"],
|
||||
.auth-card input[type="text"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* The 6-digit OTP input wants to be visually loud — it's the only
|
||||
thing the user is doing on that page. Bigger, more spacing, taller. */
|
||||
.auth-card input[name="code"] {
|
||||
font-size: 24px;
|
||||
padding: 16px 14px;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-card input:focus { border-color: var(--accent); }
|
||||
|
||||
/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
|
||||
/* Same visual treatment as auth-card so prompts read as a coherent
|
||||
family. Replaces the inline `style="padding:8px"` that left these
|
||||
inputs feeling cramped. */
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-input:focus { border-color: var(--accent); }
|
||||
.auth-card button {
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
|
|
@ -943,7 +1020,13 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.settings-section { margin-top: 22px; }
|
||||
/* Sections are <details> elements — collapsed by default to keep the
|
||||
settings page scannable. Click the summary to expand. */
|
||||
.settings-section {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--surface-2);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.settings-section__head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
|
|
@ -951,8 +1034,30 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.settings-section__head::before { content: "▸ "; color: var(--accent); }
|
||||
/* Suppress the native disclosure marker (Webkit + Firefox). */
|
||||
.settings-section__head::-webkit-details-marker { display: none; }
|
||||
.settings-section__head::marker { content: ""; }
|
||||
.settings-section__head::before {
|
||||
content: "▸";
|
||||
color: var(--accent);
|
||||
display: inline-block;
|
||||
transition: transform 120ms ease-out;
|
||||
font-size: 10px;
|
||||
}
|
||||
.settings-section[open] > .settings-section__head::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
|
||||
.settings-section__head:hover { color: var(--text); }
|
||||
.settings-section__head:hover::before { color: var(--text); }
|
||||
.settings-section__lede {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
|
|
|
|||
|
|
@ -221,10 +221,10 @@
|
|||
'A synced portfolio is available for this account (last synced ' +
|
||||
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
|
||||
'</div>' +
|
||||
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
|
||||
'<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
|
||||
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
|
||||
'autocomplete="off" placeholder="PIN" ' +
|
||||
'style="flex:0 0 140px;">' +
|
||||
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
|
||||
'<button type="submit">Restore</button>' +
|
||||
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
|
||||
'or import a new CSV →</a>' +
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
|
||||
<div class="settings-row">
|
||||
<div class="settings-row__label">Tier</div>
|
||||
<div class="settings-row__value">
|
||||
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
|
||||
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
|
||||
</span>
|
||||
|
|
@ -40,16 +41,6 @@
|
|||
{% else %}
|
||||
<span class="settings-row__hint">Paid subscription active.</span>
|
||||
{% endif %}
|
||||
{% if user.stripe_customer_id %}
|
||||
<button type="button" id="stripe-portal-btn"
|
||||
class="btn-secondary"
|
||||
style="margin-left:10px; padding:6px 14px; font-size:12px;">
|
||||
Manage subscription
|
||||
</button>
|
||||
<div class="settings-row__hint" style="margin-top:6px;">
|
||||
Update payment method, view invoices, switch monthly ↔ annual, or cancel any time. Opens the Stripe-hosted billing portal.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="settings-row__hint">
|
||||
|
|
@ -57,6 +48,16 @@
|
|||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
|
||||
<button type="button" id="stripe-portal-btn" class="settings-icon-btn"
|
||||
title="Manage subscription — payment method, invoices, plan, cancel"
|
||||
aria-label="Manage subscription">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
|
||||
|
|
@ -91,8 +92,11 @@
|
|||
{% endif %}
|
||||
|
||||
{# --- Import portfolio --------------------------------------------- #}
|
||||
<div class="settings-section" id="import">
|
||||
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
|
||||
{# Open by default because /settings#import is the deep-link target
|
||||
from the dashboard's "import a portfolio" CTA — if you arrive via
|
||||
that link the section should already be expanded. #}
|
||||
<details class="settings-section" id="import" open>
|
||||
<summary class="settings-section__head">Import portfolio (Trading 212 CSV)</summary>
|
||||
<p class="settings-section__lede">
|
||||
Export your pie from T212
|
||||
(<span class="neu">Investing → Your Pie → ··· → Export</span>)
|
||||
|
|
@ -110,11 +114,11 @@
|
|||
|
||||
<div id="import-preview" hidden style="margin-top:14px;"></div>
|
||||
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# --- Referral block ---------------------------------------------- #}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__head">Invite a friend</div>
|
||||
<details class="settings-section">
|
||||
<summary class="settings-section__head">Invite a friend</summary>
|
||||
<p class="settings-section__lede">
|
||||
Share your invite link. When your friend subscribes, you and
|
||||
they each get <strong>50% off for 3 months</strong>.
|
||||
|
|
@ -145,11 +149,11 @@
|
|||
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# --- Email digests block ------------------------------------------ #}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__head">Email digests</div>
|
||||
<details class="settings-section">
|
||||
<summary class="settings-section__head">Email digests</summary>
|
||||
<p class="settings-section__lede">
|
||||
Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap.
|
||||
</p>
|
||||
|
|
@ -188,7 +192,7 @@
|
|||
</div>
|
||||
|
||||
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
|
@ -213,8 +217,8 @@
|
|||
</script>
|
||||
|
||||
{# --- Cloud sync block --------------------------------------------- #}
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__head">Cloud sync (encrypted)</div>
|
||||
<details class="settings-section">
|
||||
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
|
||||
<p class="settings-section__lede">
|
||||
Manage the encrypted server-side copy of your portfolio. Sync is
|
||||
opted-in per import (see the Import section above).
|
||||
|
|
@ -235,7 +239,7 @@
|
|||
above to enable cloud sync.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
|
||||
|
||||
|
|
@ -261,10 +265,10 @@
|
|||
<form id="sync-modal-form" autocomplete="off">
|
||||
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
|
||||
<input id="sync-pin1" type="password" inputmode="numeric"
|
||||
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
||||
class="modal-input" required>
|
||||
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
|
||||
<input id="sync-pin2" type="password" inputmode="numeric"
|
||||
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
||||
class="modal-input" required>
|
||||
<label style="display:flex;align-items:flex-start;gap:8px;
|
||||
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
|
||||
<input id="sync-ack" type="checkbox" required>
|
||||
|
|
|
|||
|
|
@ -29,14 +29,7 @@
|
|||
<form method="post" action="/verify" autocomplete="off">
|
||||
<label>Verification code
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}"
|
||||
minlength="6" maxlength="6" required autofocus
|
||||
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
|
||||
</label>
|
||||
<label style="display:block; margin:14px 0 0; font-size:12.5px; color:var(--muted); line-height:1.55;">
|
||||
<input type="checkbox" name="subscribe_to_digests" value="on" checked
|
||||
style="vertical-align:middle; margin-right:6px;">
|
||||
Email me the digest — daily for paid, Sunday for everyone.
|
||||
One-click unsubscribe in every email.
|
||||
minlength="6" maxlength="6" required autofocus>
|
||||
</label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
|
|
|
|||
146
docs/paddle-merchant-summary.md
Normal file
146
docs/paddle-merchant-summary.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Paddle merchant onboarding — Read the Markets
|
||||
|
||||
Use this when filling Paddle's seller-application / business-description
|
||||
fields. Framing is deliberately **media / publishing**, not financial
|
||||
services — "trading", "signals", "advice" wording triggers rejection or
|
||||
sends the application to extra compliance review.
|
||||
|
||||
---
|
||||
|
||||
## Business description (one paragraph)
|
||||
|
||||
**Read the Markets** is a UK-based subscription publishing service for
|
||||
retail investors who want to *understand* what's moving in markets
|
||||
without acting on tips or signals. The site aggregates public market
|
||||
data (prices via Yahoo Finance) and public RSS news feeds, then
|
||||
generates plain-English written commentary using a large language model.
|
||||
Subscribers read; the service does not trade, hold client funds, or give
|
||||
personal financial advice. Operated by Giorgio Gilestro, ICO-registered
|
||||
as ZC098928.
|
||||
|
||||
## What we sell
|
||||
|
||||
A single B2C subscription that unlocks extended access to our written
|
||||
market commentary and personal-portfolio analysis features. There is
|
||||
one product, two billing cadences:
|
||||
|
||||
- **Read the Markets — Paid plan, Monthly** — £7 GBP / month
|
||||
- **Read the Markets — Paid plan, Annual** — £70 GBP / year
|
||||
|
||||
A free tier exists indefinitely (no card required) and gives access to
|
||||
the core editorial at reduced refresh cadence. Pricing in GBP; VAT
|
||||
handled by Paddle as merchant of record.
|
||||
|
||||
## What subscribers get on the Paid plan
|
||||
|
||||
- 24-hour news headline window (free: 6 hours)
|
||||
- Strategic interpretation log refreshed every hour during market hours
|
||||
(free: every six hours)
|
||||
- Daily written digest emailed Monday–Saturday
|
||||
- The ability to ask follow-up questions to the AI about any past
|
||||
published interpretation
|
||||
- Optional upload of a personal portfolio CSV (currently Trading 212
|
||||
export) for an AI commentary on diversification and macro-regime fit
|
||||
— purely descriptive, no buy/sell calls
|
||||
- Optional end-to-end encrypted cloud sync of the portfolio file
|
||||
|
||||
## What we explicitly do **not** do (regulatory framing)
|
||||
|
||||
- **Not a financial-advice service.** We do not produce personalised
|
||||
recommendations or consider a user's wider finances, debts, tax
|
||||
position, or objectives.
|
||||
- **No buy/sell/hold signals.** Output is editorial commentary on
|
||||
public data.
|
||||
- **No brokerage.** We never execute trades, hold client money, or
|
||||
custody assets.
|
||||
- **Not regulated under FSMA / FCA COBS.** This is explicitly stated on
|
||||
the site disclaimer and in the portfolio-analysis feature
|
||||
description.
|
||||
- **No crypto trading, no margin/leverage products, no copy-trading,
|
||||
no managed accounts.**
|
||||
- **No tipster service.** All copy emphasises the difference between
|
||||
"understanding markets" and "gambling on them."
|
||||
|
||||
## Audience
|
||||
|
||||
Retail readers in the UK and EU who want a daily macro briefing in
|
||||
plain English. Comparable to a paid newsletter (e.g. Substack finance
|
||||
writers) or a personal-finance magazine subscription, delivered as a
|
||||
web app + email.
|
||||
|
||||
## Refund & cancellation policy
|
||||
|
||||
Published at <https://read.markets/terms> §6: 14-day statutory
|
||||
cooling-off (Consumer Contracts Regulations 2013), cancel-any-time
|
||||
taking effect at end of billing period, pro-rata refund if we terminate
|
||||
service through no fault of the user. Refund requests handled by email
|
||||
at <hello@read.markets>.
|
||||
|
||||
---
|
||||
|
||||
## Comprehensive product overview (single-field answer)
|
||||
|
||||
> Use this when Paddle asks **"Could you provide a comprehensive
|
||||
> overview of your product?"** — one self-contained block, ~400
|
||||
> words, designed so the reviewer hits the "not a financial product"
|
||||
> framing within the first two sentences.
|
||||
|
||||
**Read the Markets** (<https://read.markets>) is a UK-based
|
||||
subscription publishing service that helps retail readers *understand*
|
||||
what is moving in financial markets — through plain-English written
|
||||
commentary, not through trading signals, advice, or recommendations.
|
||||
It is best understood as a digital newsletter / news-and-media
|
||||
subscription product, with an AI-content layer, comparable to a paid
|
||||
financial newsletter on Substack or a digital news magazine. We do not
|
||||
execute trades, hold client funds, custody any assets, or operate as a
|
||||
broker or investment adviser. We are not authorised by the FCA and we
|
||||
are not a regulated financial service. This positioning is explicit on
|
||||
the live site disclaimer at <https://read.markets/disclaimer> and in
|
||||
the body copy of every paid feature.
|
||||
|
||||
The product works as follows. We aggregate public market data (stock,
|
||||
FX, commodity, and rate quotes via Yahoo Finance) and public RSS news
|
||||
feeds across the macro universe. A large language model (via
|
||||
OpenRouter) then writes a short editorial interpretation of what the
|
||||
underlying public data appears to be saying. The output is a written
|
||||
article — a "strategic log" — refreshed through the trading day, plus
|
||||
per-asset-class commentary panels and a daily / weekly written digest
|
||||
delivered by email. Subscribers can also optionally upload a personal
|
||||
portfolio holdings CSV (currently exported from Trading 212) to receive
|
||||
a written sense-check of diversification, currency exposure, and
|
||||
macro-regime fit on those holdings; this output is purely descriptive
|
||||
and contains no buy, sell, or hold recommendations.
|
||||
|
||||
A free tier exists indefinitely (no card required) and serves the core
|
||||
editorial at a reduced refresh cadence (6-hour news window, strategic
|
||||
log refreshed every six hours, weekly Sunday digest only). The Paid
|
||||
plan extends those to a 24-hour news window, hourly strategic log
|
||||
refresh, daily Mon–Sat email digest, the optional portfolio upload +
|
||||
AI commentary, an interactive follow-up chat against any past
|
||||
published article, and optional encrypted cloud sync of the portfolio
|
||||
file.
|
||||
|
||||
Pricing is in GBP, with Paddle as merchant of record handling VAT:
|
||||
**£7 / month** or **£70 / year** (two months free). Subscribers can
|
||||
cancel any time, taking effect at the end of the current billing
|
||||
period. A 14-day statutory cooling-off period applies under the UK
|
||||
Consumer Contracts Regulations 2013, plus pro-rata refunds where we
|
||||
terminate service through no fault of the user — full refund policy at
|
||||
<https://read.markets/terms> §6. Operated by Giorgio Gilestro,
|
||||
ICO-registered as ZC098928, contact <hello@read.markets>.
|
||||
|
||||
---
|
||||
|
||||
## Practical tips when completing the Paddle form
|
||||
|
||||
- **Category dropdown:** pick **"Software / SaaS — Content &
|
||||
publishing"** or **"Digital subscription — News & media"** if those
|
||||
options exist. Avoid anything containing the words *financial
|
||||
services*, *trading*, *investing tools*, or *fintech*.
|
||||
- **Self-declaration:** describe the product as **media / publishing
|
||||
with an AI-content angle** — not a financial service.
|
||||
- **Linkable references for the reviewer:**
|
||||
- Pricing & tier breakdown: <https://read.markets/pricing>
|
||||
- Disclaimer (the legal "not advice" statement): <https://read.markets/disclaimer>
|
||||
- Terms & Conditions (incl. §6 Refunds): <https://read.markets/terms>
|
||||
- Privacy notice (ICO ZC098928 surfaced here): <https://read.markets/privacy>
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
"""Verify-POST persists the subscribe_to_digests form field."""
|
||||
"""/verify behaviour: returning users keep their digest preference, and
|
||||
first-login triggers the welcome email exactly once.
|
||||
|
||||
The verify page used to carry a "Email me the digest" checkbox; that
|
||||
was removed (it was misleading on repeat logins). Default-opt-in lives
|
||||
in the User row at creation; per-user changes happen on /settings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
|
@ -35,60 +41,11 @@ def _build(tmp_path):
|
|||
return TestClient(app), pending
|
||||
|
||||
|
||||
def test_verify_with_unchecked_subscribe_disables_opt_in(tmp_path, monkeypatch):
|
||||
from app.services import otp_service
|
||||
|
||||
async def _ok(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||
|
||||
client, pending = _build(tmp_path)
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000"}, # subscribe_to_digests omitted = unchecked
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303, r.text
|
||||
|
||||
async def _check():
|
||||
from app import db as db_mod
|
||||
from app.models import User
|
||||
async with db_mod._session_factory() as s:
|
||||
u = await s.get(User, 10)
|
||||
assert u.email_digest_opt_in is False
|
||||
asyncio.run(_check())
|
||||
|
||||
|
||||
def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch):
|
||||
from app.services import otp_service
|
||||
|
||||
async def _ok(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||
|
||||
client, pending = _build(tmp_path)
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000", "subscribe_to_digests": "on"},
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
|
||||
async def _check():
|
||||
from app import db as db_mod
|
||||
from app.models import User
|
||||
async with db_mod._session_factory() as s:
|
||||
u = await s.get(User, 10)
|
||||
assert u.email_digest_opt_in is True
|
||||
asyncio.run(_check())
|
||||
|
||||
|
||||
def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
|
||||
"""A user who unsubscribed (via Settings or the one-click link) must
|
||||
not be silently re-enrolled when they log in again, even if the verify
|
||||
page's default-checked checkbox gets submitted."""
|
||||
not be silently re-enrolled when they log in again. The handler now
|
||||
never touches email_digest_opt_in, so this is a regression guard
|
||||
against accidentally adding that back."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.services import otp_service
|
||||
|
|
@ -110,12 +67,9 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
|
|||
await s.commit()
|
||||
asyncio.run(_make_returning())
|
||||
|
||||
# They log in again. The verify checkbox is default-checked in the
|
||||
# template, so the form will submit "on" — but the handler must NOT
|
||||
# apply that to a returning user.
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000", "subscribe_to_digests": "on"},
|
||||
data={"code": "000000"},
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
|
@ -128,3 +82,98 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
|
|||
u = await s.get(User, 10)
|
||||
assert u.email_digest_opt_in is False, "returning user re-enrolled"
|
||||
asyncio.run(_check())
|
||||
|
||||
|
||||
def test_first_login_triggers_welcome_email(tmp_path, monkeypatch):
|
||||
"""A user signing in for the first time gets exactly one welcome
|
||||
email. The send is best-effort — failure must not block login."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.services import otp_service
|
||||
from app.routers import auth as auth_router
|
||||
|
||||
async def _ok(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||
|
||||
send_mock = AsyncMock()
|
||||
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
|
||||
|
||||
client, pending = _build(tmp_path)
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000"},
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
|
||||
assert send_mock.await_count == 1, "first login should send a welcome email"
|
||||
assert send_mock.await_args.args == ("newbie@x",)
|
||||
|
||||
|
||||
def test_returning_user_login_does_not_resend_welcome(tmp_path, monkeypatch):
|
||||
"""The welcome email is one-shot: a returning user (last_login_at
|
||||
is not None) must not get a second copy."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.services import otp_service
|
||||
from app.routers import auth as auth_router
|
||||
|
||||
async def _ok(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||
|
||||
send_mock = AsyncMock()
|
||||
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
|
||||
|
||||
client, pending = _build(tmp_path)
|
||||
|
||||
# Mark the user as already-known.
|
||||
async def _make_returning():
|
||||
from app import db as db_mod
|
||||
from app.models import User
|
||||
async with db_mod._session_factory() as s:
|
||||
u = await s.get(User, 10)
|
||||
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
|
||||
await s.commit()
|
||||
asyncio.run(_make_returning())
|
||||
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000"},
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert send_mock.await_count == 0, "returning user should not re-get welcome"
|
||||
|
||||
|
||||
def test_welcome_email_failure_does_not_block_login(tmp_path, monkeypatch):
|
||||
"""SMTP errors are best-effort — the user still gets a session cookie
|
||||
and lands on /. We rely on a log line for operational visibility."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.services import otp_service
|
||||
from app.routers import auth as auth_router
|
||||
|
||||
async def _ok(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr(otp_service, "verify", _ok)
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise RuntimeError("SMTP down")
|
||||
monkeypatch.setattr(auth_router, "send_welcome_email",
|
||||
AsyncMock(side_effect=_boom))
|
||||
|
||||
client, pending = _build(tmp_path)
|
||||
r = client.post(
|
||||
"/verify",
|
||||
data={"code": "000000"},
|
||||
cookies={"cassandra_pending": pending},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Login still succeeds; redirect to dashboard, session cookie set.
|
||||
assert r.status_code == 303, r.text
|
||||
assert r.headers.get("location") == "/"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue