Skip to content

STU-382: Build the plans surface and paywall states on existing subscription contracts - #90

Merged
sgardoll merged 4 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-382-build-the-plans-surface-and-paywall-states-on-existing
Sep 22, 2026
Merged

sgardoll merged 4 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-382-build-the-plans-surface-and-paywall-states-on-existing

Conversation

@sgardoll

Copy link
Copy Markdown
Owner

Summary

STU-382 — plans surface and paywall states backed by EXISTING subscription contracts and real tier limits, not the hero prototype's mock A$29 or a simulated purchase success.

The master tip already carried most of the plans surface. This PR closes the genuine gaps and pins behavior with falsifiable tests:

  1. Tier labels/limits agree everywhere (criterion 1). The account badge said Professional/Power Developer while topbar, plans cards, usage dialog and gating said Pro/Power. A single source src/plansSurface.js now drives all of them (Free/Pro/Power, limits 2/50/2000) and TIER_LIMITS in app.js is derived from it.
  2. Feature matrix is honest (criterion 5). Power card advertised "All models + early access" and "API & MCP Access". Neither maps to a CURRENT capability (no user-facing API/MCP product surface, no distinct early-access tier). Removed both from the plans page and the pricing modal; kept BYOK (real: "Configure API Keys"), Code Regeneration, Code Review, All models, generation limits.
  3. Checkout return alone never claims subscribed (criteria 3/4). The old code showed "Subscription active! Welcome aboard." purely from the ?checkout=success return param. Now the confirmation is deferred until the get-subscription contract reconciles to a paid tier (confirmCheckoutAfterReconcile); a blocked/lagging/cancelled return shows an honest "confirming your subscription" message and the tier stays whatever the contract says. Used real A$11/A$49 base prices, existing formatPrice/detectUserCurrency; paid downgrades/cancellation still go through the billing portal.

Acceptance-criteria checklist

  • C1 Free/Pro/Power labels, limits and current-plan indicators agree across plans, topbar, account and gating — src/plansSurface.js single source + C1 e2e asserting topbar, account badge, plans "Current plan" and model gating all say Pro for a professional contract.
  • C2 AUD and a non-AUD fixture use existing formatting and match the checkout selection — AUD (Australia/Sydney) displays A$11 and posts currency=AUD; USD (America/New_York) displays $7.15 (11×0.65 fixture rate) and posts currency=USD.
  • C3 blocked/failed/cancelled checkout never displays subscribed; verified return refreshes allowance/models — cancelled returns render Free everywhere with no "Subscription active" toast; a ?checkout=success return whose contract is still Free also renders Free (return alone grants nothing); a verified return (contract→professional) renders Pro and unlocks Pro models.
  • C4 keyboard/mobile reach sign-in, upgrade and Manage (360×800 viewport); no simulated 1.3s purchase success survives — production contains no mock "Subscription confirmed / no charge was made / Redirecting to checkout" strings; the pricing surface asserts their absence, and an upgrade POSTs to /stripe/create-checkout-session-intl (real Stripe path) without locally writing a paid subscription.
  • C5 every feature/comparison row maps to a supported CURRENT capability; unsupported prototype promises removed.

Tests + results

  • Node: npm test → 296 pass / 0 fail (290 baseline + 6 new src/plansSurface.test.js). Build: npm run build passes.
  • e2e (port 3092): full suite → 54 pass (45 pre-existing incl. 2 smoke assertions updated to the unified Pro/Power labels + 9 new STU-382 in e2e/stu382-plans-paywall.spec.js).

Falsification evidence (break → FAIL → restore → PASS)

  • C1 label agreement — broke professional label to "Professional": unit test planLabel('professional')==='Pro' FAILED, e2e account badge "Pro" FAILED; restored → PASS.
  • C5 unsupported promise — re-added "API & MCP Access" to Power features: unit test "every feature row maps to a supported capability" and "no plan card advertises a prototype-only promise" FAILED; restored → PASS.
  • C5 BYOK Power-only — leaked BYOK onto Pro: unit test "BYOK is Power-only" FAILED; restored → PASS.
  • C3 return-alone-not-paid / no-false-subscribed — reverted to the old unconditional "Subscription active" on ?checkout=success: the "return alone never grants a paid tier" e2e FAILED (asserted honest "confirming" copy / 0 "Subscription active"); restored → PASS.
  • C3 cancelled never subscribed / C1/C5 rendered surfaces — reintroduced mock/e2e breaks verified by the respective specs red→green (same discipline above).
  • C2 currency matches checkout — forced the checkout to POST currency="EUR" while AUD is displayed: AUD e2e asserted postedCurrency==="AUD" FAILED; restored → PASS.

Screenshots (changed surface)

  • screenshots/stu382-plans-professional-1440x900.png — plans page for a professional contract (current-plan indicator, A$ prices, honest feature rows).
  • screenshots/stu382-plans-guest-360x800.png — plans page at mobile width, signed-out.

What is verified vs NOT

  • Verified: label/limit agreement, honest feature matrix, reconciliation-aware checkout confirmation, currency formatting matches the posted checkout selection, no simulated purchase success, keyboard/mobile reachability of sign-in/upgrade/Manage, and both node + e2e suites green with per-assertion falsification demonstrated.
  • NOT verified: real Stripe end-to-end (browser tests stub Stripe; the repo has no CI and main is unprotected, so nothing here checks against a live checkout). The A$11/A$49 price values are asserted from BASE_PRICES_AUD, matching the checkout's tierId→STRIPE_PRICE_IDS request, but no real Stripe session was created. Screenshots are from the fixture-rendered app, not a real user account.

Linear

https://linear.app/stuart-gardoll/issue/STU-382/build-the-plans-surface-and-paywall-states-on-existing-subscription

…sting subscription contracts

- Unify Free/Pro/Power tier labels + limits across topbar, account badge, plans
  page, usage dialog and generation gating via a single src/plansSurface.js
- Reconcile the plan feature matrix to CURRENT supported capabilities: drop
  unsupported 'API & MCP Access' and 'early access' prototype promises, keep
  BYOK (Configure API Keys), Code Regeneration, Code Review
- Defer the 'plan active' confirmation until the get-subscription contract
  confirms a paid tier; a checkout return alone never claims subscribed
- Add plans/paywall e2e coverage (port 3092) and plansSurface unit tests

Generated-by: Claude
… catch a hidden mock

The design's mock success copy lived in a hidden modal, so a body.innerText
scan could not catch its reintroduction. Assert on page.content() so an
inert/hidden mock is caught. Falsified: injecting the mock reddened the test;
restored: green.

Generated-by: Claude
@sgardoll

Copy link
Copy Markdown
Owner Author

Product decisions from the owner (2026-09-22), recorded so they are not re-litigated:

  1. "Pro" is canonical. Free / Pro / Power is correct; the labels diverging on master were the bug, not the fix. Confirmed the divergence was real — app.js:5018 carried a separate map ({professional: 'Professional', power: 'Power Developer'}) driving the account badge while the topbar, plans and usage dialog already said Pro/Power. Removing it is right. Note "For FlutterFlow Professionals" (index.html:3217,3633) is descriptive marketing copy, not a tier label, and stays.

  2. KEEP the "API & MCP Access" and "All models + early access" rows (index.html:3235,3237 and 3652,3654). They were removed as unsupported prototype promises; the owner wants them retained. They must still satisfy the issue's own criterion — each row must map to a supported current capability, and anything not live yet must be presented as clearly unavailable rather than promised. A follow-up commit will restore them with truthful framing.

A follow-up pass is also addressing the adversarial review findings.

@sgardoll

Copy link
Copy Markdown
Owner Author

Correction to the product decision above (owner, 2026-09-22).

API & MCP Access IS a shipped capability. It is not self-serve — the owner's words: “it's actually shipped. You've basically got to get in contact with us to learn more.”

So the restored row must not be worded as unavailable or "coming soon". It should present the capability as real and available, with the access path stated honestly: contact-us / talk-to-us rather than self-serve signup. The "All models + early access" row should likewise be presented as available.

The general criterion still applies — no row may promise something the product cannot deliver — but for these two rows the product can deliver, so the truthful framing is "available, get in touch", not "not yet available".

Nothing else in the review findings changes.

…ore owner rows

Addresses the four review findings on the plans/paywall surface:

1. [BLOCKING] Make the plans-card price assertions hermetic. C5 asserted
   A\$11/A\$49 while formatPrice converts whenever the detected currency is
   not AUD, so CI (UTC) rendered \$7.15/\$31.85 from the same code that a
   Sydney machine renders A\$11/A\$49 from. Pin the suite's timezone/locale
   to Australia/Sydney/en-AU so the verdict no longer depends on the host;
   the now-failed-in-CI assertion passes deterministically in any timezone.

2. [major] Wire PLAN_FEATURES to the UI instead of leaving it a dead
   constant. app.js now renders every .pm-card's feature list (both the
   plans page and the pricing modal) from src/plansSurface.js PLAN_FEATURES,
   and index.html's duplicated hardcoded row copies are removed. The unit
   and browser tests now assert the constant that actually drives the DOM,
   and the pricing modal's unsupported-promise path is covered, not just the
   plans page.

3. [minor] Make the "verified return refreshes allowance" test title honest:
   assert #topbar-credits-count shows the refreshed allowance (50-12=38)
   rather than only claiming it.

4. [PRODUCT DECISION] Restore the "API & MCP access" and "All models + early
   access" rows the owner asked to keep, in both rendered surfaces, marked
   clearly not-yet-available ("(coming soon)") so nothing unavailable reads
   as available. Pro/Power remain the canonical labels (the divergent
   "Professional"/"Power Developer" display map is gone).
The previous revision restored the owner-requested rows but marked them
unavailable, on the stated reasoning that "the product exposes no user-facing
API/MCP surface". That inference was drawn from the codebase and is wrong: both
capabilities ship. API & MCP access is reached by contacting us rather than
through a self-serve signup.

- API & MCP access now renders as "API & MCP access (contact us)" — the
  capability is presented as available and names its access path.
- "All models + early access" is no longer suffixed.
- Replace UNSUPPORTED_PROMISES/UNAVAILABLE_MARKER with CONTACT_TO_ACCESS_MARKER.
  The two rows are now entries in the supported-capability whitelist, so the
  criterion-5 guard rejects a not-yet-available reading rather than requiring it.
- Tests assert the rows are NOT marked unavailable.

Falsified: reintroducing "(coming soon)" fails 1 unit test and the C5 browser
test; restoring passes 296/296 and 9/9.

Full CI sequence green: npm test 296, build, 54 browser, 7 accessibility
modals, 9/9 deploy guard, 30/30 proxies, leak scan clean.
@sgardoll
sgardoll marked this pull request as ready for review September 22, 2026 16:08
@sgardoll
sgardoll merged commit 9dcb8ce into milestone/stu-implement-updated-ui-ux Sep 22, 2026
3 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment thread app.js
confirmCheckoutAfterReconcile();
updateSubscriptionUI();
updatePricingDisplay();
renderPlanFeatureLists();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Plan features disappear during startup

When startup requests stall, renderPlanFeatureLists has not run and every plan card shows an empty feature list. The previous static rows remained visible during the same delay.

Learn more

The feature rows now exist only in JavaScript. This call runs after initializeAuth() and fetchSubscription(), which each perform network requests without a request timeout. The document remains interactive while the async DOMContentLoaded callback waits, so users can open the pricing modal and see cards with no features.

Example: A session refresh takes 20 seconds. During those 20 seconds, opening the pricing modal displays Free, Pro, and Power cards with empty feature lists instead of their plan capabilities.

Recommended fix: Call renderPlanFeatureLists() before the first startup await, since its data is local and independent of authentication. Alternatively, retain static HTML rows as the initial fallback and let the renderer replace them later.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/plansSurface.js
Comment on lines +57 to +64
power: Object.freeze([
"2000 generations/month",
"All models",
"Code Regeneration",
"Bring your own key (BYOK)",
"Code Review",
"All models + early access",
`API & MCP access ${CONTACT_TO_ACCESS_MARKER}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Feature-matrix intent is contradictory

The PR description removes API/MCP and early access as unsupported, while PLAN_FEATURES advertises both as available. Tests lock in the latter decision, so product intent needs clarification.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/plansSurface.js
Comment on lines +10 to +17
// The feature list also encodes criterion 5: a row must EITHER map to a CURRENT
// supported capability of the product, OR — when the owner has asked the row be
// kept (the "API & MCP access" and "All models + early access" rows) — be
// presented as CLEARLY not-yet-available rather than promised. Anything not
// live yet is suffixed "(coming soon)" so it can never read as a capability the
// app delivers today. This is the single source both the plans page and the
// pricing modal render from, so the two surfaces cannot drift. BYOK is real (the
// "Configure API Keys" surface) and remains listed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Feature contract contradicts itself

The opening contract requires unavailable retained rows to say “coming soon,” while later comments declare those rows shipped. Align the documentation with the final product decision.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant