Upgrading a PubTrivia venue is a Stripe Checkout session created by one route, app/api/create-checkout-session/route.ts. The pricing page shows the price, the dashboard's plans screen posts the plan, Stripe takes the card. The last two lines of the session params are these:
success_url: `${PUBLIC_URL}/dashboard/plans`, cancel_url: `${PUBLIC_URL}/dashboard/plans`, The same URL. Pay, and you come back to the plans screen. Press the browser's back arrow out of Checkout, and you come back to the plans screen. Nothing in the return trip says which of those two things happened.
That is on purpose, and it is still not free
The reason is a rule the codebase applies everywhere: the browser's return from Checkout is not evidence of anything. A success_url is a link Stripe hands the customer, and a customer can bookmark it, share it, or arrive at it with no payment behind them at all. The only thing that knows a subscription exists is the webhook. The plans page says so in a comment next to the state it renders from:
// The Stripe webhook writes the subscription's plan onto the venue, so the // row already says which paid plan this is. const currentPlanType = isActive ? (venue?.plan_type ?? 'pro') : 'trial' isActive is venue?.subscription_status === 'active', and that column is written by the webhook handler and by nothing else. So the page is honest: it tells you what we actually know.
What it costs is the few seconds after a successful payment. Stripe redirects the browser back immediately, and customer.subscription.created arrives when it arrives. If the redirect wins that race, the customer lands on a page whose heading is "Choose your plan", with their old plan still marked as current, directly after entering a card. There is nothing on the screen that says "we are finishing this off". The product looks like it dropped the payment.
The fix does not require trusting the URL. It requires distinguishing the two URLs, which is free, and then saying something true about the wait:
success_url: `${PUBLIC_URL}/dashboard/plans?checkout=complete`, cancel_url: `${PUBLIC_URL}/dashboard/plans`, With that, ?checkout=complete is a hint, not an authority: it never writes anything, it just lets the page render "payment received, your plan is updating" and re-check the venue row until the status moves. A bookmarked link shows a pending state that resolves into whatever the row really says, which is exactly what you want somebody poking at the URL to see.
There is already a page for this. Nobody can get to it.
app/subscribe/success/page.tsx exists, 40 lines, and it is the page those params used to point at. It opens with the same rule stated as a warning:
export default async function SubscribeSuccess() { // Subscription status is updated exclusively by the Stripe webhook. // Do not write plan/status here, the webhook handles it reliably. Then a card that says "Thank you for subscribing! Redirecting to dashboard...", a button to the dashboard, and this:
<script> {` setTimeout(() => { window.location.href = '/dashboard' }, 2000) `} </script> Grep the repository for /subscribe/success and you find two hits. One is the page itself. The other is __tests__/maintenance.test.ts, which asserts that the proxy rewrites it to the maintenance page along with /dashboard, /play/venue/table and the two auth callbacks. A test is the only thing left that believes in this route.
It is not public, either, so the gate answers for it:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/subscribe/success 307 https://pub-trivia.app/login?next=%2Fsubscribe%2Fsuccess The inline script surprised me, in the boring direction
I assumed that template literal was dead on arrival, because React escapes text children, and window.location.href = '/dashboard' with its quotes turned into ' is a syntax error inside a <script>. Script content is raw text in HTML, so entities in there are never decoded.
React does not do that. Rendering the same element with renderToStaticMarkup:
<div><script> setTimeout(() => { window.location.href = '/dashboard' }, 2000) </script></div> The quotes come out as quotes. script and style are raw text elements and React treats their children accordingly, which is also exactly why <script>{somethingFromAUser}</script> is an injection and not a quirk. So the redirect would work, and this page's only problem is that nothing links to it.
Three things still remember the route
robots.txt. /subscribe is one of eight disallowed prefixes, so we are spending a line telling crawlers to stay out of a path that already answers them with a redirect to a login form:
$ curl -s https://pub-trivia.app/robots.txt | grep subscribe Disallow: /subscribe The maintenance test, above, which will keep passing for as long as the folder exists and will start failing the moment somebody deletes it, which is the right way round but is a strange thing for a test to be protecting.
A column. The users table carries this:
seen_subscribe: boolean('seen_subscribe').notNull().default(false), It drives the welcome banner in the dashboard layout:
const showTrialBanner = userRecord?.seen_subscribe === false The banner is the trial welcome, and the flag means "has this person been through the signup funnel's end". It is named after a page that no longer exists in the flow it is named for, and there is a server action, app/dashboard/actions/mark-trial-seen.ts, whose filename knows the new name while the column it writes keeps the old one.
What we are doing about it
Delete the route folder and its parent, and take the matching line out of the maintenance test. Change success_url as above and give the plans screen a pending state so the webhook race stops looking like a lost payment. Leave Disallow: /subscribe for now, because the cost of a stale disallow line is zero and crawlers have seen that path before.
The column keeps its name until something else in the users table needs a migration, and then it rides along as seen_trial_welcome. Renaming a column to match a page that was deleted is the correct change and a bad use of a deploy on its own.
If you want to see the live end of this, the pricing page is the real entry point, and the trial it offers takes no card. The curl of /subscribe/success above works from anywhere, and the 307 it answers with is the whole story of a page that outlived its links.
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
