Products, categories, units and pricing
Module 10 designed the schema. This lesson creates it and fills it with real data — and real data is where the decisions you only half-made become unavoidable.
Create the schema
The schema from module 10 is already in your project. Migrate it:
cd apps/api
npx prisma migrate dev --name initial_schema
Read the generated SQL. It is what runs in production, and reading it is how you notice a change you did not intend.
npx prisma studio
Empty tables, which is the problem this lesson solves.
The questions real data forces
You cannot seed a kirana catalogue without answering these, and each is a decision rather than a lookup.
How is this sold?
Aashirvaad Atta 5 kg pack one item, fixed weight
Tomatoes per kg weighed at packing
Eggs per piece sold in 6s, 12s, 30s
Milk 1 L pouch fixed
Loose dal per 500 g weighed
The Unit enum covers all of these, and the quantity decimal is what makes
them comparable — 500 g is 0.5 with unit KILOGRAM, or 500 with unit
GRAM. Both are expressible, and you must pick one convention or price
comparison breaks.
Our convention: store in the smallest sensible unit for the category.
Weights in grams, volumes in millilitres, counts as pieces. So a 5 kg pack is
quantity: 5000, unit: GRAM.
That makes price-per-unit a division with no unit conversion, which is worth the slightly odd-looking data.
What is a variant, and what is a separate product?
The test: would a customer searching for one be happy to find the other?
- Tata Salt 1 kg and Tata Salt 2 kg → same product, two variants. Yes.
- Aashirvaad Atta and Pillsbury Atta → different products. Different brands.
- Amul Milk and Amul Butter → different products, obviously.
- Tomatoes 500 g and Tomatoes 1 kg → same product, two variants.
Borderline: Aashirvaad Select Atta and Aashirvaad Multigrain Atta. Different products — a customer wanting multigrain is not served by select. Brand is shared, product is not.
Get this wrong towards "too many products" rather than "too many variants". Splitting a product later is easy; merging two products that customers have ordered from means reconciling order history.
MRP, or just a price?
pricePaise Int
mrpPaise Int?
MRP is the printed maximum. A shop selling below it wants to show the saving; one selling at MRP has nothing to show.
So mrpPaise is nullable and only set when it differs from the price. A
product where both are equal should have mrpPaise: null, not a duplicate — or
your interface shows "₹285, was ₹285", which looks broken.
Enforce it where the data is created rather than in the display code:
mrpPaise: mrp && mrp > price ? mrp : null,
The seed
// apps/api/prisma/seed.ts
import { PrismaClient, Unit } from "@prisma/client";
const prisma = new PrismaClient();
const CATEGORIES = [
{ slug: "staples", name: "Staples & Grains", sortOrder: 1 },
{ slug: "vegetables", name: "Fresh Vegetables", sortOrder: 2 },
{ slug: "dairy", name: "Dairy & Eggs", sortOrder: 3 },
{ slug: "snacks", name: "Snacks & Beverages", sortOrder: 4 },
{ slug: "household", name: "Household", sortOrder: 5 },
];
type SeedVariant = {
sku: string;
label: string;
unit: Unit;
quantity: number;
pricePaise: number;
mrpPaise?: number;
stock: number;
};
type SeedProduct = {
slug: string;
name: string;
brand?: string;
category: string;
description?: string;
variants: SeedVariant[];
};
const PRODUCTS: SeedProduct[] = [
{
slug: "aashirvaad-select-atta",
name: "Aashirvaad Select Sharbati Atta",
brand: "Aashirvaad",
category: "staples",
description:
"Stone-ground whole wheat flour made from Sharbati wheat. Soft rotis that stay soft.",
variants: [
{ sku: "AAS-ATTA-5", label: "5 kg", unit: Unit.GRAM, quantity: 5000, pricePaise: 28500, mrpPaise: 31000, stock: 12 },
{ sku: "AAS-ATTA-10", label: "10 kg", unit: Unit.GRAM, quantity: 10000, pricePaise: 56000, mrpPaise: 62000, stock: 4 },
],
},
{
slug: "tata-salt",
name: "Tata Salt Iodised",
brand: "Tata",
category: "staples",
variants: [
// Deliberately out of stock: the empty state gets exercised on every load.
{ sku: "TATA-SALT-1", label: "1 kg", unit: Unit.GRAM, quantity: 1000, pricePaise: 2800, stock: 0 },
],
},
{
slug: "toor-dal",
name: "Toor Dal (Arhar)",
category: "staples",
variants: [
{ sku: "DAL-TOOR-500", label: "500 g", unit: Unit.GRAM, quantity: 500, pricePaise: 9500, stock: 30 },
{ sku: "DAL-TOOR-1000", label: "1 kg", unit: Unit.GRAM, quantity: 1000, pricePaise: 18000, stock: 18 },
],
},
{
slug: "tomato",
name: "Tomato",
category: "vegetables",
variants: [
{ sku: "VEG-TOM-500", label: "500 g", unit: Unit.GRAM, quantity: 500, pricePaise: 2500, stock: 40 },
{ sku: "VEG-TOM-1000", label: "1 kg", unit: Unit.GRAM, quantity: 1000, pricePaise: 4800, stock: 25 },
],
},
{
slug: "amul-taaza-toned-milk",
// Long on purpose: the product grid must survive it.
name: "Amul Taaza Homogenised Toned Milk Tetra Pak Long Life",
brand: "Amul",
category: "dairy",
variants: [
{ sku: "AMUL-MILK-1L", label: "1 L", unit: Unit.MILLILITRE, quantity: 1000, pricePaise: 7500, stock: 30 },
],
},
{
slug: "eggs",
name: "Farm Fresh Eggs",
category: "dairy",
variants: [
{ sku: "EGG-6", label: "6 pieces", unit: Unit.PIECE, quantity: 6, pricePaise: 4200, stock: 20 },
{ sku: "EGG-12", label: "12 pieces", unit: Unit.PIECE, quantity: 12, pricePaise: 8000, stock: 15 },
{ sku: "EGG-30", label: "30 pieces", unit: Unit.PIECE, quantity: 30, pricePaise: 19000, mrpPaise: 21000, stock: 6 },
],
},
];
async function main() {
console.log("Seeding…");
await prisma.cartItem.deleteMany();
await prisma.cart.deleteMany();
await prisma.variant.deleteMany();
await prisma.product.deleteMany();
await prisma.category.deleteMany();
for (const category of CATEGORIES) {
await prisma.category.create({ data: category });
}
for (const { category, variants, ...product } of PRODUCTS) {
await prisma.product.create({
data: {
...product,
category: { connect: { slug: category } },
variants: {
create: variants.map((v) => ({
...v,
// Never store an MRP equal to the price.
mrpPaise: v.mrpPaise && v.mrpPaise > v.pricePaise ? v.mrpPaise : null,
})),
},
},
});
}
console.log({
categories: await prisma.category.count(),
products: await prisma.product.count(),
variants: await prisma.variant.count(),
});
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => prisma.$disconnect());
// apps/api/package.json
"prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" }
npx prisma db seed
Why this data and not forty random rows
Every entry earns its place, the same argument as the Python seeding lesson:
Real names show whether the card handles them. "Aashirvaad Select Sharbati Atta" is a realistic length.
One deliberately very long name — the Amul entry — so a grid that breaks under it breaks on every page load rather than in front of a customer.
Tata Salt at zero stock so the out-of-stock state is exercised constantly and cannot rot.
MRP on some and not others, so both price displays appear.
One, two and three variants across products, because the variant selector must handle all three.
Eggs in pieces, milk in millilitres, dal in grams, so unit handling is tested rather than assumed.
Price per unit
Real data makes this possible, and it is genuinely useful to a customer:
// packages/shared/src/catalogue.ts
export function pricePerUnit(variant: {
pricePaise: number;
quantity: number;
unit: Unit;
}): { paise: number; unit: string } | null {
switch (variant.unit) {
case "GRAM":
return { paise: Math.round((variant.pricePaise / variant.quantity) * 1000), unit: "kg" };
case "MILLILITRE":
return { paise: Math.round((variant.pricePaise / variant.quantity) * 1000), unit: "L" };
case "PIECE":
return { paise: Math.round(variant.pricePaise / variant.quantity), unit: "piece" };
default:
return null;
}
}
Toor dal at ₹95 for 500 g is ₹190/kg; at ₹180 for 1 kg it is ₹180/kg. The customer can see the larger pack is cheaper — and with eggs, that the 30-pack is ₹6.33 each against ₹7.00 for the six.
This only works because the unit convention is consistent. Storing one product in kilograms and another in grams would make the comparison wrong, and wrong quietly.
Check your work
The unit convention: store in the smallest sensible unit — grams, millilitres, pieces — so price-per-unit needs no conversion.
Variant or separate product: would a customer searching for one be happy to find the other? Yes means variants of one product.
Which way to err: towards too many products. Splitting later is easy; merging products that have order history is not.
When mrpPaise should be null: whenever it is not greater than the price,
so the interface never shows "₹285, was ₹285".
Where that rule belongs: where the data is created, not in display code.
Why Tata Salt is seeded at zero stock: so the out-of-stock state is exercised on every page load and cannot rot unnoticed.
Why one product name is absurdly long: so a grid that cannot handle it fails in development rather than in front of a customer.
Why price-per-unit depends on the convention: mixing grams and kilograms across products would make comparisons silently wrong.
Practice
- Migrate the schema and read the generated SQL.
- Write and run the seed. Confirm the counts.
- Open Prisma Studio and check the units — confirm the 5 kg pack is 5000 grams.
- Add a product where MRP equals the price. Confirm the seed stores null.
- Write
pricePerUnitand print it for every variant. Check the eggs. - Deliberately store one product in kilograms and confirm the comparison goes wrong. Then fix it.
- Add a product you would find in a real kirana shop that none of these cover. Decide its unit and whether it is a variant of something.
- Decide, with a reason: is "Aashirvaad Atta" and "Aashirvaad Multigrain Atta" one product or two?
- Run the seed twice and confirm you get no duplicates.
Next: the API that serves this catalogue.
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