Skip to content

Commit b82e772

Browse files
LEANDERANTONYclaude
andcommitted
fix(pricing): enforce the Free export entitlement + correct two unwired claims
Pricing-claims audit (A2 / report.md item 70b19c9) cross-referenced every landing pricing bullet against the wired backend/tiers.py caps and gating code. Three claims were not backed by code: 1. Pro "Unlimited job searches + saved jobs" — job_searches Pro is UNLIMITED but saved_jobs Pro is wired to 1000 (saved_jobs_service reads TIER_CAPS). "Unlimited ... saved jobs" was literally false. -> copy: "Unlimited job searches, 1,000 saved jobs". 2. Free "PDF export, ATS theme" vs Pro "PDF + DOCX export, all themes" implied a tier paywall on export_format/theme, but NOTHING gated it — every tier check was on counters only, and /workspace/artifacts/export took no auth at all. A Free user could already download DOCX / any theme. Fabricated differentiation. -> WIRE THE GATE so the existing copy becomes true (chosen over rewording): new backend.tiers.export_entitlement_block_reason (policy, single source of truth, lock-stepped with the pricing copy) + backend.quota.enforce_export_entitlement (raiser). It reuses the canonical QuotaExceededError 429 path exactly like the premium_applications "Pro+ feature" rejection, so the frontend renders the uniform upgrade nudge with zero new error plumbing. Wired into BOTH export routes: /workspace/resume-builder/export (already authed) and /workspace/artifacts/export (added the missing get_optional_auth_tokens dependency). Tier resolution is best-effort — anon/expired -> "free" (most restrictive); the Free-allowed pdf+classic_ats path is unchanged for every tier incl. anonymous, so no legitimate export breaks. 3. Business "SSO, admin dashboard, shared shortlists" — zero wiring anywhere (no SSO/SAML/SCIM/admin-dashboard/shortlist code). -> copy: "SSO & admin controls on request" (honest for a mailto-only Business tier). Tests: tests/backend/test_export_entitlement.py — 14 hermetic tests (pure policy + raiser; no Supabase/OpenAI so they run regardless of local .env). Backend import graph verified (no cycle: workspace -> quota -> tiers; tiers lazy-imports subscriptions). Frontend tsc + eslint clean. Follow-up (documented in report.md, deliberately NOT done here to avoid scope creep / overlap with the pending Premium-toggle A2 items): ArtifactViewer could pre-disable the DOCX button + theme options for Free with an inline upgrade hint. Today a Free DOCX click returns the standard 429 upgrade toast (humanizeApiError + the existing CTA) — consistent with every other quota wall, not user-hostile, just one click less polished. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e368c8 commit b82e772

5 files changed

Lines changed: 293 additions & 4 deletions

File tree

backend/quota.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@
4747
from datetime import datetime, timezone
4848
from typing import Optional
4949

50-
from backend.tiers import TIER_CAPS, UNLIMITED, Tier
50+
from backend.tiers import (
51+
TIER_CAPS,
52+
UNLIMITED,
53+
Tier,
54+
export_entitlement_block_reason,
55+
)
5156
from src.config import (
5257
SUPABASE_SERVICE_ROLE_KEY,
5358
SUPABASE_URL,
@@ -153,6 +158,44 @@ def _build_quota_exceeded_error(
153158
)
154159

155160

161+
def enforce_export_entitlement(
162+
tier: Tier,
163+
*,
164+
export_format: str | None = None,
165+
themes: tuple[str, ...] | list[str] = (),
166+
) -> None:
167+
"""Raise ``QuotaExceededError`` if `tier` may not export with the
168+
requested format/theme; no-op otherwise.
169+
170+
The PDF/DOCX + theme split is an ENTITLEMENT, not a metered
171+
counter, so -- exactly like the ``premium_applications`` "Pro+
172+
feature" rejection in `_build_quota_exceeded_error` -- it reuses
173+
the canonical 429 path so the frontend renders the uniform upgrade
174+
nudge instead of a bespoke error shape. No-op for Pro/Business and
175+
for Free's allowed ``pdf`` + ``classic_ats`` combination.
176+
177+
``counter="premium_export"`` is a display-routing hint only (it is
178+
deliberately NOT a ``TIER_CAPS`` counter -- there is no count to
179+
track); ``reset_period="lifetime"`` keeps the frontend from
180+
rendering a wrong "resets on the 1st" line, mirroring how the
181+
premium-application entitlement rejection is shaped.
182+
"""
183+
reason = export_entitlement_block_reason(
184+
tier, export_format=export_format, themes=themes
185+
)
186+
if reason is None:
187+
return
188+
raise QuotaExceededError(
189+
f"{reason} is a Pro+ feature. Upgrade to unlock DOCX downloads "
190+
"and additional export themes.",
191+
counter="premium_export",
192+
current=0,
193+
cap=0,
194+
reset_period="lifetime",
195+
tier=tier,
196+
)
197+
198+
156199
# ─── Backend abstraction ────────────────────────────────────────────────
157200

158201

backend/routers/workspace.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from fastapi.responses import StreamingResponse
33

44
from backend.observability import capture_event
5+
from backend.quota import enforce_export_entitlement
56
from backend.rate_limit import LIMIT_HEAVY, LIMIT_LLM, LIMIT_PARSE, limiter
67
from backend.request_auth import get_optional_auth_tokens
8+
from backend.tiers import resolve_user_tier
79
from backend.services.auth_session_service import (
810
build_openai_service_for_context,
911
resolve_authenticated_context,
@@ -96,6 +98,29 @@ def _raise_http_error(error: AppError):
9698
raise HTTPException(status_code=400, detail=error.user_message)
9799

98100

101+
def _resolve_export_tier(access_token: str, refresh_token: str):
102+
"""Best-effort tier for an export request.
103+
104+
Export of the Free-allowed PDF + classic_ats combination must keep
105+
working for everyone (incl. anonymous / expired sessions), so this
106+
NEVER raises on missing/invalid auth -- it resolves to the most
107+
restrictive tier ("free") instead. Only the DOCX / custom-theme
108+
entitlement is gated, by ``enforce_export_entitlement``; a Free or
109+
anonymous caller requesting the allowed combination is unaffected.
110+
"""
111+
if not (access_token and refresh_token):
112+
return "free"
113+
try:
114+
auth_context = resolve_authenticated_context(
115+
access_token=access_token,
116+
refresh_token=refresh_token,
117+
)
118+
except AppError:
119+
return "free"
120+
app_user = getattr(auth_context, "app_user", None)
121+
return resolve_user_tier(app_user)
122+
123+
99124
def _resolve_openai_service(access_token: str, refresh_token: str):
100125
"""Best-effort OpenAIService for an authenticated request.
101126
@@ -482,6 +507,16 @@ def export_resume_builder_route(
482507
`export_resume_builder_artifact()` helper.
483508
"""
484509
access_token, refresh_token = auth_tokens
510+
# Pricing-truth gate: Free is "PDF export, ATS theme". DOCX / a
511+
# non-classic_ats theme is a Pro+ entitlement -> canonical 429.
512+
# Checked before any session hydrate so a blocked request has no
513+
# side effects (and QuotaExceededError bypasses the ValueError
514+
# catch below straight to the global 429 handler).
515+
enforce_export_entitlement(
516+
_resolve_export_tier(access_token or "", refresh_token or ""),
517+
export_format=payload.export_format,
518+
themes=(payload.theme,),
519+
)
485520
try:
486521
hydrate_resume_builder_session_if_needed(
487522
access_token=access_token or "",
@@ -902,7 +937,23 @@ def remove_saved_job_route(job_id: str, auth_tokens=Depends(get_optional_auth_to
902937

903938
@router.post("/artifacts/export")
904939
@limiter.limit(LIMIT_PARSE)
905-
def export_workspace_artifact_route(request: Request, payload: WorkspaceArtifactExportRequestModel):
940+
def export_workspace_artifact_route(
941+
request: Request,
942+
payload: WorkspaceArtifactExportRequestModel,
943+
auth_tokens=Depends(get_optional_auth_tokens),
944+
):
945+
access_token, refresh_token = auth_tokens
946+
# Pricing-truth gate (P0): this route previously took no auth at
947+
# all, so the Free "PDF export, ATS theme" vs Pro "PDF + DOCX,
948+
# all themes" pricing split was unenforced. Resolve the tier
949+
# (best-effort; anon/expired -> "free") and block the DOCX /
950+
# non-classic_ats entitlement with the canonical 429 upgrade
951+
# nudge. PDF + classic_ats is unchanged for every tier incl. anon.
952+
enforce_export_entitlement(
953+
_resolve_export_tier(access_token or "", refresh_token or ""),
954+
export_format=payload.export_format,
955+
themes=(payload.resume_theme, payload.cover_letter_theme),
956+
)
906957
try:
907958
return export_workspace_artifact(
908959
workspace_snapshot=payload.workspace_snapshot,

backend/tiers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,56 @@ def retention_days_for_tier(tier: Tier) -> int | None:
169169
return _RETENTION_DAYS_BY_TIER[tier]
170170

171171

172+
# ── Export entitlement (pricing-truth gate) ─────────────────────────
173+
# The pricing page promises Free "PDF export, ATS theme" and Pro /
174+
# Business "PDF + DOCX export, all themes". That differentiation is an
175+
# ENTITLEMENT, not a metered counter, so it lives here (tier policy)
176+
# and is enforced via the same QuotaExceededError 429 path as the
177+
# premium_applications gate (see
178+
# `backend.quota.enforce_export_entitlement` /
179+
# `_build_quota_exceeded_error`) -- the frontend then renders the
180+
# identical upgrade nudge instead of a bespoke error shape.
181+
#
182+
# Keep these two constants in lockstep with the pricing copy in
183+
# `frontend/src/components/landing-page.tsx` (Free vs Pro/Business
184+
# feature bullets). Changing what Free gets is a pricing change, not a
185+
# refactor.
186+
FREE_EXPORT_FORMAT = "pdf"
187+
FREE_EXPORT_THEME = "classic_ats"
188+
189+
190+
def export_entitlement_block_reason(
191+
tier: Tier,
192+
*,
193+
export_format: str | None = None,
194+
themes: tuple[str, ...] | list[str] = (),
195+
) -> str | None:
196+
"""Return a short human label for the locked feature if `tier` may
197+
NOT export with the requested format/theme, else ``None``.
198+
199+
Pro and Business have the full entitlement (always ``None``). Free
200+
-- and anonymous, which `resolve_user_tier` already collapses to
201+
"free" -- is limited to ``FREE_EXPORT_FORMAT`` +
202+
``FREE_EXPORT_THEME``; anything else returns the label the upgrade
203+
nudge should name ("DOCX export" / "Custom export themes").
204+
205+
Theme/format comparison is whitespace- and case-insensitive to
206+
match the request models' ``_strip_theme`` normalisation. An
207+
empty/blank value is treated as the default (allowed), never a
208+
violation -- a caller omitting a theme must not be upsold.
209+
"""
210+
if tier != "free":
211+
return None
212+
fmt = (export_format or "").strip().lower()
213+
if fmt and fmt != FREE_EXPORT_FORMAT:
214+
return "DOCX export"
215+
for theme in themes:
216+
normalized = (theme or "").strip().lower()
217+
if normalized and normalized != FREE_EXPORT_THEME:
218+
return "Custom export themes"
219+
return None
220+
221+
172222
def resolve_user_tier(app_user: AppUserRecord | None) -> Tier:
173223
"""Resolve the active subscription tier for an authenticated user.
174224

frontend/src/components/landing-page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,7 @@ const PRICING_TIERS: PricingTier[] = [
11091109
features: [
11101110
"20 tailored applications / month",
11111111
"5 premium applications with GPT-5.5",
1112-
"Unlimited job searches + saved jobs",
1112+
"Unlimited job searches, 1,000 saved jobs",
11131113
"150 assistant chat turns / month",
11141114
"PDF + DOCX export, all themes",
11151115
"30-day workspace history",
@@ -1133,7 +1133,7 @@ const PRICING_TIERS: PricingTier[] = [
11331133
"80 tailored applications / seat",
11341134
"25 premium applications with GPT-5.5",
11351135
"500 assistant chat turns / seat",
1136-
"SSO, admin dashboard, shared shortlists",
1136+
"SSO & admin controls on request",
11371137
"Unlimited history, no retention TTL",
11381138
"Priority email support",
11391139
],
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Export-entitlement gate (pricing-truth).
2+
3+
The landing pricing page promises Free "PDF export, ATS theme" and
4+
Pro/Business "PDF + DOCX export, all themes". Before this gate that
5+
differentiation was UNENFORCED -- any tier (and `/workspace/artifacts/
6+
export` had no auth at all) could download DOCX / any theme, making
7+
the Free vs Pro pricing bullets a fabricated claim.
8+
9+
`backend.tiers.export_entitlement_block_reason` is the policy (single
10+
source of truth, lock-stepped with the pricing copy);
11+
`backend.quota.enforce_export_entitlement` is the raiser that reuses
12+
the canonical `QuotaExceededError` 429 path (exactly like the
13+
`premium_applications` "Pro+ feature" rejection) so the frontend
14+
renders the uniform upgrade nudge.
15+
16+
These tests are deliberately hermetic (pure functions, no Supabase /
17+
OpenAI) so they run regardless of local `.env` credentials.
18+
"""
19+
from __future__ import annotations
20+
21+
import pytest
22+
23+
from backend.quota import enforce_export_entitlement
24+
from backend.tiers import (
25+
FREE_EXPORT_FORMAT,
26+
FREE_EXPORT_THEME,
27+
export_entitlement_block_reason,
28+
)
29+
from src.errors import QuotaExceededError
30+
31+
32+
# ── policy: export_entitlement_block_reason ─────────────────────────
33+
34+
35+
def test_free_pdf_classic_ats_is_allowed():
36+
assert (
37+
export_entitlement_block_reason(
38+
"free", export_format="pdf", themes=("classic_ats", "classic_ats")
39+
)
40+
is None
41+
)
42+
43+
44+
def test_free_docx_is_blocked_with_docx_label():
45+
assert (
46+
export_entitlement_block_reason("free", export_format="docx")
47+
== "DOCX export"
48+
)
49+
50+
51+
def test_free_non_ats_theme_is_blocked_with_theme_label():
52+
assert (
53+
export_entitlement_block_reason(
54+
"free", export_format="pdf", themes=("professional_neutral",)
55+
)
56+
== "Custom export themes"
57+
)
58+
59+
60+
def test_free_format_violation_takes_precedence_over_theme():
61+
# DOCX + a custom theme: the format label is the one we surface.
62+
assert (
63+
export_entitlement_block_reason(
64+
"free", export_format="docx", themes=("professional_neutral",)
65+
)
66+
== "DOCX export"
67+
)
68+
69+
70+
def test_blank_or_default_values_never_upsell_free():
71+
# A caller omitting a theme (cover-letter export with default
72+
# resume_theme, etc.) must not be upsold.
73+
assert export_entitlement_block_reason("free") is None
74+
assert (
75+
export_entitlement_block_reason(
76+
"free", export_format="", themes=("", " ")
77+
)
78+
is None
79+
)
80+
81+
82+
def test_free_comparison_is_case_and_whitespace_insensitive():
83+
# Mirrors the request models' _strip_theme normalisation.
84+
assert (
85+
export_entitlement_block_reason(
86+
"free", export_format=" PDF ", themes=(" Classic_ATS ",)
87+
)
88+
is None
89+
)
90+
91+
92+
@pytest.mark.parametrize("tier", ["pro", "business"])
93+
def test_paid_tiers_have_full_entitlement(tier):
94+
assert (
95+
export_entitlement_block_reason(
96+
tier, export_format="docx", themes=("professional_neutral",)
97+
)
98+
is None
99+
)
100+
101+
102+
def test_free_constants_match_pricing_copy():
103+
# If these change, the Free pricing bullet ("PDF export, ATS
104+
# theme") changed too -- a pricing decision, caught here.
105+
assert FREE_EXPORT_FORMAT == "pdf"
106+
assert FREE_EXPORT_THEME == "classic_ats"
107+
108+
109+
# ── raiser: enforce_export_entitlement ──────────────────────────────
110+
111+
112+
def test_enforce_allows_free_pdf_classic_ats():
113+
# No exception == allowed.
114+
enforce_export_entitlement(
115+
"free", export_format="pdf", themes=("classic_ats",)
116+
)
117+
118+
119+
@pytest.mark.parametrize("tier", ["pro", "business"])
120+
def test_enforce_allows_paid_docx(tier):
121+
enforce_export_entitlement(
122+
tier, export_format="docx", themes=("professional_neutral",)
123+
)
124+
125+
126+
def test_enforce_blocks_free_docx_as_quota_error():
127+
with pytest.raises(QuotaExceededError) as exc_info:
128+
enforce_export_entitlement("free", export_format="docx")
129+
err = exc_info.value
130+
# Reuses the canonical 429 shape (same as premium_applications):
131+
assert err.tier == "free"
132+
assert err.counter == "premium_export"
133+
assert err.cap == 0
134+
assert err.current == 0
135+
# lifetime, NOT a monthly counter -> frontend won't render a wrong
136+
# "resets on the 1st" line.
137+
assert err.reset_period == "lifetime"
138+
assert "Pro+" in err.user_message
139+
140+
141+
def test_enforce_blocks_free_custom_theme():
142+
with pytest.raises(QuotaExceededError):
143+
enforce_export_entitlement(
144+
"free", export_format="pdf", themes=("professional_neutral",)
145+
)

0 commit comments

Comments
 (0)