sp.units Query Param Shows 5x Price for a 1-Unit Invite
This article may contain affiliate links. Its content is not affected by advertising.
In short
The invite apply page's server action always confirms 1 unit, but its display used the raw sp.units query param, so ?units=5 kept showing 5 units and 5x the price for a 1-unit contract.
Conclusion
The invite-link apply page always confirms 1 unit server-side, but the screen’s own display used the raw units query parameter. Visit it with ?units=5 and the contract that actually gets signed is 1 unit, while the screen keeps showing “5 units, 5x the price.”
Symptom
This portal’s apply page (/apply/[id]) lets an ordinary applicant choose a number of units via ?units=. It’s the path for a single company buying multiple units at once.
The same page is also opened via invite links issued by referral partners. On the invite path, the business logic only ever allows “one company = one unit,” so the server-side confirmation handler (actions.ts) has long forced units to 1 unconditionally whenever the request came through an invite.
// Invite-path unit count is **fixed at 1** (Reviewer S6). units comes from the
// query string / a hidden field, and the only upper bound is remaining capacity —
// scanning a printed QR code once could otherwise claim all 30 remaining units.
// The business rule is "1 company = 1 unit," so restricting this doesn't get in
// the way of legitimate applications.
const applicantInput = {
loopId,
units: invite ? 1 : units,
...
The problem was the display side of this same page. Regardless of whether the request came through an invite, the unit count and price shown on screen simply rounded the raw sp.units query parameter and used it as-is.
// Per-application: normalize the query's unit count / period server-side
// (tamper resistance; final capacity is re-verified by the reservation RPC).
const parsedUnits = parseInt(sp.units ?? "1", 10);
const units = Math.min(
Math.max(1, Number.isFinite(parsedUnits) ? parsedUnits : 1),
Math.max(1, loop.remainingUnits)
);
Visit an invite link with ?units=5 appended, and this units becomes 5 as long as there’s remaining capacity. Every downstream unit count and price display uses this value, so the screen shows “5 units = duration × 5” and “price × 5.” The applicant applies based on what they see — but actions.ts, the code behind the form submission, overwrites units to 1 the moment it detects the invite path, so the contract actually formed is for 1 unit only.
What the applicant confirmed on screen — the quantity and price — and what the resulting contract actually says did not match.
Cause
In one sentence, the cause is that the same rule, “the invite path locks to 1 unit,” was only ever implemented server-side.
actions.ts(confirms the application, a server action): detects the invite path and branchesunits: invite ? 1 : units— this was already fixedpage.tsx(the apply page’s display): never checks the invite path; its calculation just rounds the query parameter regardless
Exactly when the server-side branch went in isn’t shown in the diff examined here (the commit that fixed the display side doesn’t touch actions.ts — meaning it already existed by that point). So chronologically, the server-side safeguard went in first, and the display side simply never caught up to it.
The fix
The same invite branch used in actions.ts was added to the display-side units calculation in page.tsx.
const parsedUnits = parseInt(sp.units ?? "1", 10);
// The invite path is locked to 1 company = 1 unit (the apply action confirms
// under the same rule). Without matching this, ?units=5 would show
// "5 units, 5x the price" while only signing a 1-unit contract
// (raised again by Reviewer).
const units = invite
? 1
: Math.min(
Math.max(1, Number.isFinite(parsedUnits) ? parsedUnits : 1),
Math.max(1, loop.remainingUnits)
);
Now, whatever query parameter is appended to an invite link, the display always shows 1 unit and the price for 1 unit — matching what actions.ts actually confirms.
Why it wasn’t noticed
What makes this mismatch tricky is that looking at the server side alone makes everything look “fixed.” Check the contracts database and every invite-based application is recorded at 1 unit, with nothing abnormal in sight. The mismatch only ever showed up on “the screen the applicant was looking at in the moment” — it left no trace in any record.
The server-side safeguard and the display use the same word, units, but they live in separate files and functions, so there’s no guarantee that fixing one keeps the other in step. This one took two rounds of review to surface: the first round added the server-side lock, and only in the second round was it pointed out that “the screen still isn’t fixed.” A rule enforced in more than one place is structurally easy to consider “done” after fixing only one of them.
Frequently asked questions
Q1Why did the displayed and actual unit counts diverge?
The display-side units calculation in page.tsx rounded the raw sp.units query param with Math.min regardless of the invite path, while the server action in actions.ts always forced units: 1 for invite-based signups. Only the display side failed to reflect the server-side rule.
Q2When was the server-side 1-unit lock added?
As far as could be confirmed, the server-side fix to force 1 unit (referred to in the code as Reviewer S6) predates the display-side fix in commit ead0be0 and is not part of that diff. The server side was fixed first; the display-side miss was found later.
Q3Does this happen on non-invite applications too?
No. For ordinary, non-invite applications, both the display and server units come from the same query param rounded with the same Math.min rule, so they cannot diverge. The mismatch only exists on the invite path, where only the server side had a 1-unit special case.
Q4How was it fixed?
By adding the same invite branch used on the server action side to the display-side units calculation in page.tsx: invite ? 1 : (the rounded query value). Now the display locks to 1 for invite signups, matching what actually gets confirmed.
Environment verified
- Next.js 16.2.7 / React 19.2.4 (kimiteras-portal)
- Found and fixed the same day, 2026-07-24, during internal re-review
What this article is based on
- TypeScript file lines 121-126commit 408053a
- TypeScript file lines 125-134commit ead0be0
- TypeScript file lines 98-106commit ead0be0
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.