Managing inventory and prices
Stock and prices are the two numbers a shop changes every day, and both are easy to get wrong in ways that cost real money.
The page opens on the answer
orderBy: [{ stock: "asc" }, { sku: "asc" }],
Out of stock first, then low, then everything else.
The shopkeeper opens this page to answer one question: what do I need to reorder? Sorting alphabetically makes them scan fourteen rows to find the two that matter. Sorting by stock puts the answer at the top.
This is a small thing that separates an internal tool people use from one they work around. Sort by what the page is for, not by what is tidy.
Setting stock is a write, not an increment
...(dto.stock !== undefined ? { stock: dto.stock } : {}),
Module 12 spent a lesson insisting that stock must never be read and written back. This is the exception, and understanding why it is not a contradiction matters.
Selling is a claim on a shared resource: two customers competing, neither
knowing about the other, and the database has to arbitrate. That needs
decrement in a conditional update.
Setting stock from the admin screen is a declaration. The shopkeeper has counted the shelf. There are eleven. Not "eleven more" — eleven. Their count is the truth, and it should replace whatever the system believed.
If two shopkeepers count the same shelf at the same time you have a different problem, and it is not one the database can solve.
Price is entered in rupees and stored in paise
const [rupees, setRupees] = useState((row.pricePaise / 100).toFixed(2));
const paise = Math.round(Number(rupees) * 100);
Nobody types 28500 for ₹285. The input takes rupees; the wire carries paise;
decision 0001 stands.
Math.round, never Math.floor. 285.15 * 100 in a float is
285.14999999999998. Math.floor gives 28514 and the shop loses a paisa on
every sale of that item — which is trivial until it is an audit question nobody
can explain.
Only send a change
onBlur={() => {
const next = Number(stock);
if (Number.isInteger(next) && next >= 0 && next !== row.stock) {
save({ stock: next });
}
}}
A blur handler that always fires means a request every time the shopkeeper tabs past a field they did not edit. On a counter with a weak connection, that is a page that feels broken.
Three conditions: it parses as an integer, it is not negative, and it is different from what is already there.
Refuse an empty change rather than accepting it
const changes = Object.entries(dto).filter(([, value]) => value !== undefined);
if (changes.length === 0) {
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST,
"Nothing to change.");
}
There is a trap in that first line worth knowing about, because it is invisible and it shipped once here before being caught by a test.
The obvious version is Object.keys(dto).length === 0. It is never zero.
class-transformer builds a real instance of UpdateVariantDto, so every
declared property exists on the object — the optional ones simply hold
undefined. An empty PATCH body sailed through and was accepted as a silent
no-op that returned 200.
Count the defined values, not the keys.
And refuse rather than shrug. A 200 that changed nothing is a request that will be retried, logged as a success, and eventually blamed for a change it did not make.
Guard the values the database will happily accept
@Min(100)
@Max(10_000_000)
pricePaise?: number;
The database is perfectly happy with a price of 0 or of ₹1,00,000 for a kilo of onions. Neither is a price; both are typos, and the second is the one that costs the shop money when somebody notices before the shop does.
if (mrpPaise !== null && mrpPaise > 0 && mrpPaise <= pricePaise) {
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST,
"The MRP must be higher than the price, or left empty.",
{ field: "mrpPaise" });
}
An MRP at or below the price renders as "₹285, was ₹285" or, worse, as a negative discount. Refuse it at the API rather than teaching the storefront to hide it — because the next surface that renders a price will not know to.
Note that the check compares the incoming price with the existing MRP when only one of them is being changed:
const pricePaise = dto.pricePaise ?? existing.pricePaise;
const mrpPaise = dto.mrpPaise ?? existing.mrpPaise;
A partial update that validates only the fields present is a partial update that lets you reach an invalid state in two steps.
Taking something off sale, not deleting it
<button onClick={() => save({ isActive: !row.isActive })}>
{row.isActive ? "Take off sale" : "Put back on sale"}
</button>
Decision 0006: nothing in this application deletes a product or a variant.
Tomatoes come off the list in the monsoon and back in October. Deleting and re-creating gives the product a new id, so every report that grouped by product now sees two products. And orders reference variants for reporting — deleting one either breaks that reference or cascades into order history, which is an accounting problem rather than a data-modelling one.
isActive = false is a button away from being undone. A delete is a restore
from backup.
The catalogue already filters on it, in one place:
where: { isActive: true, variants: { some: { isActive: true } } }
Both halves matter. A product whose variants are all inactive must not appear, or the storefront renders a card with no price.
Changing a price does not change past orders
Worth saying explicitly because it is the question everybody asks.
Order lines snapshot unitPricePaise at checkout. Raise the price of dal this
afternoon and yesterday's orders still say what they cost. The cart is the
opposite — it reads the price live, so a cart from last week shows today's
price, and the checkout page says so out loud.
That combination is deliberate: a cart is a wish, an order is a contract.
The one number nobody asked for but everybody wants
Total value on the shelf:{" "}
{formatPaise(rows.reduce((sum, row) => sum + row.pricePaise * row.stock, 0))}
Computed on the page from data already fetched — no extra query, no new endpoint. A small shop's working capital is mostly sitting on its shelves, and the shopkeeper has never had a number for it before.
Things like this are why it is worth watching somebody use the tool you built.
Check your work
Why inventory sorts by stock: the page exists to answer "what do I reorder?" and should open on the answer.
Why setting stock is a plain write: it is a declaration by somebody who counted the shelf, not a claim on a contested resource.
Why selling still needs a conditional update: two customers competing is exactly the race a plain write cannot survive.
Why Math.round and not Math.floor: 285.15 * 100 is
285.14999999999998, and flooring loses a paisa every time.
Why the blur handler compares before sending: otherwise tabbing past a field fires a request.
Why Object.keys(dto).length is never zero: class-transformer creates every
declared property; the optional ones hold undefined.
Why an empty change is refused: a 200 that changed nothing gets retried and later blamed.
Why a price of 0 is rejected: the database accepts it and it is always a typo.
Why MRP is validated against the merged values: validating only the fields present lets you reach an invalid state in two requests.
Why nothing is deleted: seasonal products, report continuity, and order lines that must not lose their reference.
Practice
- Set a variant's stock to 0 and reload the inventory page. Confirm it moves to the top.
- Type a stock value, then tab away without changing it. Confirm no request is sent.
- Enter
285.15as a price and check the storedpricePaise. Then changeMath.roundtoMath.floorand do it again. - Send
PATCH /api/admin/variants/:idwith an empty body. Confirm 400. - Replace the check with
Object.keys(dto).length === 0, restart, and send the empty body again. Watch it return 200. - Try to set a price of 0, and then of ₹2,00,000. Read both messages.
- Set an MRP below the price. Then set only the price, higher than an existing valid MRP, and confirm that is refused too.
- Take a variant off sale and confirm it vanishes from
/productsand from the product page — but is still in the inventory list. - Order something, then change its price, then look at the order. Confirm the order is unchanged and the cart would not be.
- Add fifty variants to the seed and time the inventory page. Decide at what point it needs pagination, and write down the number.
Next: telling the customer what is happening, without becoming the shop that gets muted.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship