phase D milestones 1+2: referral system + paid-access gate
Lays the billing-prep spine before Paddle lands in D.3.
D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
referred, created_at, converted_at, credited_at). converted_at /
credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
with one-click copy, pending/converted/active-credits stats grid.
Settings nav link added to the top bar.
Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)
D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
paid-tier access. Null = no credit. Populated by admin CLI now and the
D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
(active / source / expires_at / days_remaining), is_paid_active() with
admin-bearer-token bypass, and a require_paid FastAPI dependency that
raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
grant-credit is idempotent — extends from max(now, current expiry) so
re-running the command never erodes an existing grant.
Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
This commit is contained in:
parent
2013bfa8cc
commit
9759080134
18 changed files with 1159 additions and 21 deletions
|
|
@ -774,6 +774,7 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
.badge--analysis-speculative { color: var(--accent); }
|
||||
|
||||
.badge--ver { color: var(--dim); }
|
||||
.badge--ok { color: var(--positive); border-color: var(--positive); }
|
||||
|
||||
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
|
||||
|
||||
|
|
@ -882,6 +883,139 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
|||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.auth-info--invited {
|
||||
/* Slightly warmer / friendlier shading for the referral banner. */
|
||||
border-left-color: var(--positive);
|
||||
background: color-mix(in srgb, var(--positive) 7%, transparent);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
|
||||
|
||||
/* --- Settings page --------------------------------------------------- */
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--surface-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-row__label {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 10.5px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.settings-row__value { color: var(--text); }
|
||||
.settings-row__hint {
|
||||
color: var(--dim);
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.settings-section { margin-top: 22px; }
|
||||
.settings-section__head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.settings-section__head::before { content: "▸ "; color: var(--accent); }
|
||||
.settings-section__lede {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
|
||||
|
||||
.invite-block {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.invite-block__label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.invite-block__label:not(:first-child) { margin-top: 12px; }
|
||||
.invite-block__code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--accent);
|
||||
text-align: center;
|
||||
user-select: all;
|
||||
}
|
||||
.invite-block__link {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.invite-block__link input {
|
||||
flex: 1;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.invite-block__link button {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.invite-block__link button:hover { opacity: 0.85; }
|
||||
|
||||
.invite-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.invite-stats > div {
|
||||
background: var(--surface);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.invite-stats__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.invite-stats__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.auth-card__lede {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue