Under every price on the CogniPrep dashboard there is now a line of small print: "Can't afford this? Tell us what you'd pay." It opens a two-field form, one number and one optional comment.
The product decision behind it is that this is feedback and not a support request. Nobody replies, nothing is promised, and the copy is careful not to imply otherwise: the dialog says "We read every answer and use them when we set our prices", and the success toast says the same thing. If you are going to ask somebody to tell you your price is too high, the least you can do is not pretend a human is about to negotiate with them.
The engineering decision is that the browser is trusted with almost none of it.
What the client is allowed to send
const priceFeedbackSchema = z.object({ product: z.enum(PRICE_FEEDBACK_PRODUCTS), provider: z.string().max(64).nullable().optional(), suggested: z.number().finite().min(0).max(PRICE_FEEDBACK_SUGGESTION_MAX), // A little over the stored cap, so a comment that only exceeds it after a // paste with trailing whitespace is trimmed rather than rejected. comment: z .string() .max(PRICE_FEEDBACK_COMMENT_MAX * 2) .nullable() .optional(), }); Four fields: which card, which provider if the card was a provider unlock, the number, and the comment. Not the price they were looking at. Not their currency. Not their country.
Those are all re-resolved in the route, by the same functions that priced the page in the first place:
const [currency, region, country] = await Promise.all([ getDisplayCurrency(), getPriceRegion(), getVisitorCountry(), ]); await captureServerEvent( user!.id, PRICE_FEEDBACK_EVENT, priceFeedbackProperties({ ...parsed.data, currency, region, country }) ); That is not a security measure, or not mainly. It is a data integrity one. The whole value of this dataset is being able to say "answers from country X cluster at 40 percent of the price we show there", and that sentence falls apart if the price and the country are two numbers a client was free to invent or to get out of date. The page and the route read the same x-vercel-ip-country and run it through the same region table, so the price in the answer is the price that was on the screen.
The comment cap is doubled in the schema on purpose. The stored cap is 500 characters, and a 500-character comment pasted with a trailing newline would otherwise be a validation error rather than a trim. The route takes up to 1,000 and the cleaner does the trimming:
export function cleanPriceFeedbackComment(comment: string | null | undefined): string | null { if (!comment) return null; const cleaned = comment.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trim(); return cleaned ? cleaned.slice(0, PRICE_FEEDBACK_COMMENT_MAX) : null; } The suggestion cap is 10,000,000 in the visitor's own currency units, and the comment next to it says exactly what it is for: "Guards typos only." A cap that pretends to be a business rule is worse than one that admits it is a sanity check.
The event is captured on the server, and that is the point
/** * Each answer is captured server-side as the PostHog event * PRICE_FEEDBACK_EVENT, from the route rather than the browser so an ad * blocker cannot drop it, and with the country read from the same request * header that set the visitor's prices. */ Client-side analytics has a known, large, and non-random hole in it. Blockers are not evenly distributed across your users, and I would bet money they are over-represented among the people who think your price is too high. An event fired from the browser would be a sample of the people who both disagree with the price and have no blocker installed, which is not a sample I want to set prices from.
Firing it from the route makes the delivery as reliable as the HTTP request the user just watched succeed.
Three numbers so answers from different countries can be compared
return { product: input.product, provider: input.product === 'provider_unlock' ? (input.provider ?? null) : null, country: input.country, price_region: input.region, currency: input.currency, list_price_gbp: listPence, shown_price: shownPrice, suggested_price: suggested, suggested_price_gbp: Math.round((suggested / CURRENCIES[input.currency].rate) * 100), suggested_share: shownPrice > 0 ? Math.round((suggested / shownPrice) * 100) / 100 : null, comment: cleanPriceFeedbackComment(input.comment), }; suggested_price is kept exactly as typed, in the currency they typed it in, because that is the raw answer and I do not want to lose it to a conversion. suggested_price_gbp is the same figure at the catalogue rate, for totals. And suggested_share is the one that actually gets read: the suggestion as a fraction of the price they were shown, which reads the same in every currency.
The test for a rupee answer is the clearest description of the whole module:
it('keeps a rupee suggestion as typed and converts it for comparison', () => { const props = priceFeedbackProperties({ product: 'exercises', suggested: 299, currency: 'inr', region: 'india', country: 'IN', }); expect(props.shown_price).toBe(674.99); expect(props.suggested_price).toBe(299); // ₹299 at the catalogue rate of 129.64 to the pound. expect(props.suggested_price_gbp).toBe(231); expect(props.suggested_share).toBe(0.44); }); ₹674.99 shown, ₹299 offered, 0.44. One number a product decision can be made from, and it did not need a currency column to make sense.
The function that builds all of this is pure. The route resolves the visitor and passes the three values in, which is why the test above is five lines and needs no request, no mocked headers and no database.
The input field was the fiddly part
Two small things that the obvious implementation gets wrong.
A currency symbol inside the field needs a different amount of left padding depending on how long it is, and symbols are not all one character:
const symbolPadding = symbol.length <= 1 ? 'pl-8' : symbol.length === 2 ? 'pl-10' : 'pl-13'; One character for ₹ and €, two for kr and R$, three for CHF. The symbol itself is not hard-coded anywhere; it is pulled out of Intl.NumberFormat for the visitor's locale by formatting zero and finding the currency part.
And a number typed by a human is not a number:
/** Reads "1,299", "1299.50" or "12,50" as a number. Null when it is not one. */ function parseAmount(value: string): number | null { const compact = value.replace(/[\s ]/g, ''); if (!compact) return null; // A single comma followed by one or two digits is a decimal comma; any other // comma is a thousands separator. const normalised = /^\d+,\d{1,2}$/.test(compact) ? compact.replace(',', '.') : compact.replace(/,/g, ''); if (!/^\d+(\.\d+)?$/.test(normalised)) return null; const amount = Number(normalised); return Number.isFinite(amount) && amount <= PRICE_FEEDBACK_SUGGESTION_MAX ? amount : null; } 12,50 is twelve fifty in most of Europe and 1,299 is one thousand two hundred and ninety nine almost everywhere else, and the only way to tell them apart is the digit count after the comma. The character class in that first replace is [\s\u00a0], a no-break space spelled out next to \s, which is belt and braces: JavaScript's \s already matches U+00A0, along with the narrow one at U+202F that several of the locales we format for use as a group separator. I left the redundant member in because a reader should not have to know the exact contents of \s to see that a pasted figure is handled.
Returning null rather than 0 matters too: parseFloat('abc') style coercion would have silently sent a zero, and a dataset full of zeros from people who typed "free lol" is a dataset that says nothing.
See it
The prices are public. cogniprep.app/pricing renders them per request in your own currency, and that is the figure the form quotes back at you.
The endpoint is not public, and it will tell you so without any guessing:
curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/api/pricing/price-feedback 405. There is only a POST handler, and the POST needs a session, because an answer is attributed to an account. The form itself sits under each price on the dashboard, so a free account is enough to open it, and the number you type is the one thing in the request we take your word for.

